@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.
- package/LICENSE +21 -0
- package/README.md +50 -2
- package/dist/main/agent.js +80 -0
- package/dist/main/approvals.js +68 -0
- package/dist/main/artifacts.js +166 -0
- package/dist/main/auth.js +109 -0
- package/dist/main/context.js +20 -0
- package/dist/main/desk-api.js +249 -0
- package/dist/main/env.js +104 -0
- package/dist/main/faux.js +12 -0
- package/dist/main/files.js +156 -0
- package/dist/main/folder-info.js +36 -0
- package/dist/main/folders.js +102 -0
- package/dist/main/fork.js +25 -0
- package/dist/main/fs-routes.js +69 -0
- package/dist/main/git.js +117 -0
- package/dist/main/headless.js +128 -0
- package/dist/main/history-worker.js +18 -0
- package/dist/main/history.js +151 -0
- package/dist/main/host.js +210 -0
- package/dist/main/login.js +27 -0
- package/dist/main/names.js +15 -0
- package/dist/main/observed-revision.js +69 -0
- package/dist/main/preview.js +141 -0
- package/dist/main/sanitize.js +28 -0
- package/dist/main/settings.js +242 -0
- package/dist/main/space.js +198 -0
- package/dist/main/tasks.js +92 -0
- package/dist/main/times.js +28 -0
- package/dist/main/usage.js +102 -0
- package/dist/main/works.js +21 -0
- package/dist/shared/files.js +2 -0
- package/dist/shared/frames.js +13 -0
- package/dist/shared/git.js +2 -0
- package/dist/shared/history.js +5 -0
- package/dist/shared/settings.js +2 -0
- package/out/renderer/assets/index-C3fCoBdq.css +1 -0
- package/out/renderer/assets/index-DiDTmq9b.js +45 -0
- package/out/renderer/favicon-32.png +0 -0
- package/out/renderer/favicon.svg +1 -0
- package/out/renderer/icon.png +0 -0
- package/out/renderer/index.html +18 -0
- package/out/renderer/manifest.webmanifest +10 -0
- package/package.json +64 -5
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ForkRefusedError } from "@vincemakes/kiso-runtime";
|
|
2
|
+
import { Refusal } from "./files.js";
|
|
3
|
+
/**
|
|
4
|
+
* Fork a Work: the runtime's SessionStore.fork (one rule for the terminal, the web app and the products, plan fork rev 2),
|
|
5
|
+
* after the run that ended at `atSeq` (absent: the last). The runtime copies the log to that run's end, writes the fork's
|
|
6
|
+
* sidecar (the parent's profile, "… (fork)", its origin) and gives the fork note to the model with the fork's first input,
|
|
7
|
+
* after the copied history: never in the system prompt, where it cost the fork's first request the whole history's cache
|
|
8
|
+
* (0% against 88% measured on co). The host still remembers the lineage for the forks it made before.
|
|
9
|
+
*/
|
|
10
|
+
export function forkWork(space, settings, parent, atSeq) {
|
|
11
|
+
if (!space.store.ids().includes(parent))
|
|
12
|
+
throw new Refusal(404, "no such Work in this folder");
|
|
13
|
+
let made;
|
|
14
|
+
try {
|
|
15
|
+
made = space.store.fork(parent, atSeq === undefined ? {} : { afterSeq: atSeq });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof ForkRefusedError)
|
|
19
|
+
throw new Refusal(409, error.message.replace(/session/g, "Work"));
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const fork = { parent, title: made.origin.title, atSeq: made.origin.afterSeq, at: made.origin.at };
|
|
23
|
+
settings.forked(parent, made.id, fork);
|
|
24
|
+
return { id: made.id, fork };
|
|
25
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readdirSync, realpathSync } from "node:fs";
|
|
3
|
+
import { listDir, readPreview, readTaskLog, Refusal } from "./files.js";
|
|
4
|
+
/**
|
|
5
|
+
* The read doors of the page: a Work's folder (a tree, a file, the list the
|
|
6
|
+
* composer's @ picks from) and a background task's log. GET only, behind the host's guard; every path is
|
|
7
|
+
* judged by files.ts, a task id only by the task manager of a session that
|
|
8
|
+
* exists in the Work's folder.
|
|
9
|
+
*/
|
|
10
|
+
export const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
11
|
+
export function deskRead(url, space) {
|
|
12
|
+
try {
|
|
13
|
+
const rel = url.searchParams.get("path") ?? "";
|
|
14
|
+
if (url.pathname === "/desk/task-log") {
|
|
15
|
+
const session = url.searchParams.get("session") ?? "";
|
|
16
|
+
const task = url.searchParams.get("task") ?? "";
|
|
17
|
+
if (!SAFE_ID.test(session) || !SAFE_ID.test(task) || !space.store.ids().includes(session))
|
|
18
|
+
throw new Refusal(404, "no such task");
|
|
19
|
+
const info = space.tasks.managerFor(session).list().find((t) => t.id === task);
|
|
20
|
+
if (info === undefined)
|
|
21
|
+
throw new Refusal(404, "no such task");
|
|
22
|
+
return { status: 200, body: readTaskLog(info.outputPath) };
|
|
23
|
+
}
|
|
24
|
+
let root;
|
|
25
|
+
try {
|
|
26
|
+
root = realpathSync(space.workspace);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Refusal(404, "the folder is gone");
|
|
30
|
+
}
|
|
31
|
+
return { status: 200, body: url.pathname === "/desk/fs/tree" ? listDir(root, rel) : readPreview(root, rel) };
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof Refusal)
|
|
35
|
+
return { status: error.status, body: { code: "refused", message: error.message } };
|
|
36
|
+
return { status: 500, body: { code: "internal", message: "unreadable" } };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const AT_CAP = 2000;
|
|
40
|
+
const AT_SKIP = new Set([".git", "node_modules", ".kiso", "dist", "build", ".next", ".venv", "__pycache__"]);
|
|
41
|
+
/** The files `@` can name, as the terminal lists them: git's own list (tracked and untracked, ignores honoured), else a bounded walk; `truncated` when the cap cut it. */
|
|
42
|
+
export function atFiles(space) {
|
|
43
|
+
let files;
|
|
44
|
+
try {
|
|
45
|
+
files = execFileSync("git", ["ls-files", "-c", "-o", "--exclude-standard"], { cwd: space.workspace, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1 << 24, timeout: 3000 }).split("\n").filter((p) => p !== "");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
files = [];
|
|
49
|
+
const walk = (dir, prefix) => {
|
|
50
|
+
try {
|
|
51
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
52
|
+
if (files.length > AT_CAP)
|
|
53
|
+
return;
|
|
54
|
+
if (AT_SKIP.has(e.name))
|
|
55
|
+
continue;
|
|
56
|
+
if (e.isDirectory())
|
|
57
|
+
walk(`${dir}/${e.name}`, `${prefix}${e.name}/`);
|
|
58
|
+
else if (e.isFile())
|
|
59
|
+
files.push(`${prefix}${e.name}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// an unreadable directory adds nothing
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
walk(space.workspace, "");
|
|
67
|
+
}
|
|
68
|
+
return { files: files.slice(0, AT_CAP), truncated: files.length > AT_CAP };
|
|
69
|
+
}
|
package/dist/main/git.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { Refusal } from "./files.js";
|
|
3
|
+
/**
|
|
4
|
+
* The rail's Changes from git, and the one write it can make on the person's word: commit the files they ticked, push the branch
|
|
5
|
+
* and open a pull request with `gh`. Reads are bounded (a timeout each, nothing on a folder outside git); the writes run only
|
|
6
|
+
* from the dialog's confirm (POST), never by themselves, and refuse what they cannot do in words.
|
|
7
|
+
*/
|
|
8
|
+
const run = (cwd, cmd, args, timeout = 3000) => new Promise((resolve, reject) => execFile(cmd, [...args], { cwd, timeout, maxBuffer: 1 << 23, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1" } }, (err, out, errOut) => (err === null ? resolve(out) : reject(new Error((errOut || err.message).trim())))));
|
|
9
|
+
const tryRun = (cwd, cmd, args, timeout) => run(cwd, cmd, args, timeout).then((o) => o.trim(), () => null);
|
|
10
|
+
let ghSignedIn = null;
|
|
11
|
+
/** `gh auth status`, asked once per host process: a sign-in does not come and go between two clicks. */
|
|
12
|
+
const ghReady = (cwd) => (ghSignedIn ??= tryRun(cwd, "gh", ["auth", "status"], 5000).then((o) => o !== null));
|
|
13
|
+
async function baseOf(cwd) {
|
|
14
|
+
const head = await tryRun(cwd, "git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
15
|
+
if (head !== null)
|
|
16
|
+
return head.replace(/^origin\//, "");
|
|
17
|
+
for (const name of ["main", "master"])
|
|
18
|
+
if ((await tryRun(cwd, "git", ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`])) !== null)
|
|
19
|
+
return name;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/** The changed paths and their two-letter codes (`git status -z`: NUL-separated, so a path with a space or a quote is read whole; a rename gives its new path). */
|
|
23
|
+
async function statusOf(cwd) {
|
|
24
|
+
const out = (await run(cwd, "git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"]).catch(() => "")).split("\0");
|
|
25
|
+
const map = new Map();
|
|
26
|
+
for (let i = 0; i < out.length; i++) {
|
|
27
|
+
const e = out[i];
|
|
28
|
+
if (e.length < 4)
|
|
29
|
+
continue;
|
|
30
|
+
map.set(e.slice(3), e.slice(0, 2));
|
|
31
|
+
if (e[0] === "R" || e[0] === "C")
|
|
32
|
+
i += 1; // the old path follows a rename or a copy
|
|
33
|
+
}
|
|
34
|
+
return map;
|
|
35
|
+
}
|
|
36
|
+
/** The base as a ref to compare with: origin's copy when there is one (what the PR is judged against), else the local branch. */
|
|
37
|
+
const baseRef = async (cwd, base) => ((await tryRun(cwd, "git", ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${base}`])) !== null ? `origin/${base}` : base);
|
|
38
|
+
export async function gitOf(cwd) {
|
|
39
|
+
const none = { repo: false, branch: null, base: null, ahead: 0, files: [], dirty: false, canPr: false, pr: null };
|
|
40
|
+
if ((await tryRun(cwd, "git", ["rev-parse", "--is-inside-work-tree"])) !== "true")
|
|
41
|
+
return none;
|
|
42
|
+
const branch = await tryRun(cwd, "git", ["symbolic-ref", "--short", "HEAD"]);
|
|
43
|
+
const base = await baseOf(cwd);
|
|
44
|
+
const ref = base === null ? null : await baseRef(cwd, base);
|
|
45
|
+
const fork = ref === null ? null : await tryRun(cwd, "git", ["merge-base", ref, "HEAD"]);
|
|
46
|
+
const ahead = ref === null ? 0 : Number((await tryRun(cwd, "git", ["rev-list", "--count", `${ref}..HEAD`])) ?? 0);
|
|
47
|
+
const dirty = await statusOf(cwd);
|
|
48
|
+
const numstat = fork === null ? "" : await run(cwd, "git", ["diff", "--numstat", "-z", "--no-renames", fork]).catch(() => "");
|
|
49
|
+
const files = new Map();
|
|
50
|
+
for (const entry of numstat.split("\0").filter((l) => l !== "")) {
|
|
51
|
+
const [a, r, path] = entry.split("\t");
|
|
52
|
+
files.set(path, { path, added: a === "-" ? null : Number(a), removed: r === "-" ? null : Number(r), state: "modified", dirty: dirty.has(path) });
|
|
53
|
+
}
|
|
54
|
+
for (const [path, code] of dirty) {
|
|
55
|
+
const state = code.includes("?") || code.includes("A") ? "new" : code.includes("D") ? "deleted" : "modified";
|
|
56
|
+
files.set(path, { ...(files.get(path) ?? { path, added: null, removed: null }), state, dirty: true });
|
|
57
|
+
}
|
|
58
|
+
const remote = await tryRun(cwd, "git", ["remote", "get-url", "origin"]);
|
|
59
|
+
const canPr = branch !== null && base !== null && branch !== base && remote !== null && (await ghReady(cwd));
|
|
60
|
+
let pr = null;
|
|
61
|
+
if (canPr) {
|
|
62
|
+
const json = await tryRun(cwd, "gh", ["pr", "view", "--json", "number,title,url,state,statusCheckRollup"], 6000);
|
|
63
|
+
if (json !== null) {
|
|
64
|
+
const p = JSON.parse(json);
|
|
65
|
+
const checks = p.statusCheckRollup ?? [];
|
|
66
|
+
pr = { number: p.number, title: p.title, url: p.url, state: p.state, checks: { total: checks.length, done: checks.filter((c) => c.status === "COMPLETED").length, failed: checks.filter((c) => c.conclusion === "FAILURE").length } };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { repo: true, branch, base, ahead, files: [...files.values()], dirty: dirty.size > 0, canPr, pr };
|
|
70
|
+
}
|
|
71
|
+
/** Commit the files the person ticked, with their message: those paths only (never `git add -A`), so nothing they did not see is committed. */
|
|
72
|
+
export async function commit(cwd, message, paths) {
|
|
73
|
+
if (message.trim() === "")
|
|
74
|
+
throw new Refusal(400, "a commit needs a message");
|
|
75
|
+
if (paths.length === 0)
|
|
76
|
+
throw new Refusal(400, "no file was chosen");
|
|
77
|
+
const listed = await statusOf(cwd);
|
|
78
|
+
const strange = paths.filter((p) => !listed.has(p));
|
|
79
|
+
if (strange.length > 0)
|
|
80
|
+
throw new Refusal(400, `not a changed file of this folder: ${strange.join(", ")}`);
|
|
81
|
+
await run(cwd, "git", ["add", "--", ...paths]);
|
|
82
|
+
await run(cwd, "git", ["commit", "-m", message, "--", ...paths]).catch((e) => {
|
|
83
|
+
throw new Refusal(409, `git refused the commit: ${e.message}`);
|
|
84
|
+
});
|
|
85
|
+
return { sha: (await run(cwd, "git", ["rev-parse", "--short", "HEAD"])).trim() };
|
|
86
|
+
}
|
|
87
|
+
/** Push the branch and open its pull request into the base; refused with the reason when the folder cannot. */
|
|
88
|
+
export async function openPr(cwd, title, body) {
|
|
89
|
+
const g = await gitOf(cwd);
|
|
90
|
+
if (!g.canPr || g.branch === null || g.base === null)
|
|
91
|
+
throw new Refusal(409, "a pull request needs a branch other than the base, a remote named origin, and gh signed in");
|
|
92
|
+
if (g.dirty)
|
|
93
|
+
throw new Refusal(409, "commit the changes first: a pull request carries commits, not the working tree");
|
|
94
|
+
if (g.ahead === 0)
|
|
95
|
+
throw new Refusal(409, `${g.branch} has no commit that ${g.base} does not have`);
|
|
96
|
+
if (title.trim() === "")
|
|
97
|
+
throw new Refusal(400, "a pull request needs a title");
|
|
98
|
+
await run(cwd, "git", ["push", "-u", "origin", "HEAD"], 60_000).catch((e) => {
|
|
99
|
+
throw new Refusal(409, `git push failed: ${e.message}`);
|
|
100
|
+
});
|
|
101
|
+
const out = await run(cwd, "gh", ["pr", "create", "--base", g.base, "--head", g.branch, "--title", title, "--body", body], 60_000).catch((e) => {
|
|
102
|
+
throw new Refusal(409, `gh pr create failed: ${e.message}`);
|
|
103
|
+
});
|
|
104
|
+
const url = out.trim().split("\n").pop() ?? "";
|
|
105
|
+
return { number: Number(/\/pull\/(\d+)/.exec(url)?.[1] ?? 0), url };
|
|
106
|
+
}
|
|
107
|
+
/** The dialog's starting draft from the branch's own commits: one commit gives its subject; several, the branch's name and a list. */
|
|
108
|
+
export async function prDraft(cwd) {
|
|
109
|
+
const g = await gitOf(cwd);
|
|
110
|
+
if (g.base === null)
|
|
111
|
+
return { title: "", body: "" };
|
|
112
|
+
const ref = await baseRef(cwd, g.base);
|
|
113
|
+
const subjects = ((await tryRun(cwd, "git", ["log", "--format=%s", `${ref}..HEAD`])) ?? "").split("\n").filter((s) => s !== "").reverse();
|
|
114
|
+
if (subjects.length === 1)
|
|
115
|
+
return { title: subjects[0], body: "" };
|
|
116
|
+
return { title: (g.branch ?? "").replace(/^[^/]*\//, "").replace(/[-_]/g, " "), body: subjects.map((s) => `- ${s}`).join("\n") };
|
|
117
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { kisoHome } from "@vincemakes/kiso-runtime";
|
|
6
|
+
import { revokeSessions } from "./auth.js";
|
|
7
|
+
import { startFromEnv } from "./env.js";
|
|
8
|
+
/**
|
|
9
|
+
* `kiso-web [--port 4317] [--no-open] [--revoke-sessions]`: the host in this process, the page at http://127.0.0.1:<port>, and the
|
|
10
|
+
* default browser opened once on `#c=<one-time connect code>`. The port is fixed (a page added to the Dock keeps its address): if
|
|
11
|
+
* another kiso-web host holds it, the browser is opened on that host; anything else holding it is an error that names the process.
|
|
12
|
+
* The server runs while the command runs. Configuration beyond the flags: env.ts. It prints `READY {json}` once it listens (the proofs
|
|
13
|
+
* read it); with `--no-open` it also prints the address to open, whose code is good once, for two minutes.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_PORT = 4317;
|
|
16
|
+
const USAGE = "usage: kiso-web [--port <n>] [--no-open] [--revoke-sessions]";
|
|
17
|
+
const say = (line) => void process.stdout.write(`${line}\n`);
|
|
18
|
+
const fail = (line) => {
|
|
19
|
+
process.stderr.write(`kiso-web: ${line}\n`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
};
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
let port = Number(process.env["KISO_WEB_PORT"] || DEFAULT_PORT);
|
|
24
|
+
let open = true;
|
|
25
|
+
let revoke = false;
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const arg = argv[i];
|
|
28
|
+
if (arg === "--no-open")
|
|
29
|
+
open = false;
|
|
30
|
+
else if (arg === "--revoke-sessions")
|
|
31
|
+
revoke = true;
|
|
32
|
+
else if (arg === "--port" || arg.startsWith("--port="))
|
|
33
|
+
port = Number(arg.includes("=") ? arg.slice(7) : argv[++i]);
|
|
34
|
+
else if (arg === "--help" || arg === "-h")
|
|
35
|
+
(say(USAGE), process.exit(0));
|
|
36
|
+
else
|
|
37
|
+
fail(`unknown argument ${arg}\n${USAGE}`);
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
40
|
+
fail(`--port must be a port number\n${USAGE}`);
|
|
41
|
+
return { port, open, revoke };
|
|
42
|
+
}
|
|
43
|
+
/** Where a running host leaves its scripts' token for another `kiso-web` on the same port (mode 0600, removed on exit). */
|
|
44
|
+
const hostFile = (home, port) => join(home, `web-host-${port}.json`);
|
|
45
|
+
/** Who answers on the port: a kiso-web host, something else, or nobody. */
|
|
46
|
+
async function probe(port) {
|
|
47
|
+
try {
|
|
48
|
+
const r = await fetch(`http://127.0.0.1:${port}/auth/status`, { signal: AbortSignal.timeout(1_500) });
|
|
49
|
+
return (await r.json().catch(() => ({}))).app === "kiso-web" ? "kiso-web" : "other";
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const code = error.cause?.code;
|
|
53
|
+
return code === "ECONNREFUSED" ? "free" : "other";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The process listening on the port, in words, for the error. */
|
|
57
|
+
function holder(port) {
|
|
58
|
+
try {
|
|
59
|
+
const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
60
|
+
const pid = /^p(\d+)$/m.exec(out)?.[1];
|
|
61
|
+
const command = /^c(.+)$/m.exec(out)?.[1];
|
|
62
|
+
if (pid !== undefined)
|
|
63
|
+
return `${command ?? "a process"} (pid ${pid})`;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// no lsof, or nothing found
|
|
67
|
+
}
|
|
68
|
+
return "another program";
|
|
69
|
+
}
|
|
70
|
+
function openBrowser(url) {
|
|
71
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
72
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
73
|
+
child.on("error", () => say(`Open ${url} in your browser.`));
|
|
74
|
+
child.unref();
|
|
75
|
+
}
|
|
76
|
+
/** A connect address from the host already on the port, by the token it left; without one, the bare address (a browser signed in before still is). */
|
|
77
|
+
async function addressFrom(home, port) {
|
|
78
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
79
|
+
try {
|
|
80
|
+
const { token } = JSON.parse(readFileSync(hostFile(home, port), "utf8"));
|
|
81
|
+
const r = await fetch(`${origin}/auth/code`, { method: "POST", headers: { authorization: `Bearer ${token}` } });
|
|
82
|
+
if (r.ok)
|
|
83
|
+
return `${origin}/#c=${(await r.json()).code}`;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// fall through
|
|
87
|
+
}
|
|
88
|
+
return `${origin}/`;
|
|
89
|
+
}
|
|
90
|
+
async function main() {
|
|
91
|
+
const { port, open, revoke } = parseArgs(process.argv.slice(2));
|
|
92
|
+
const home = process.env["KISO_WEB_DATA"] ?? kisoHome();
|
|
93
|
+
if (revoke)
|
|
94
|
+
return say(`kiso-web: ${revokeSessions(home)} session(s) revoked; each browser signs in again from kiso web.`);
|
|
95
|
+
if (port !== 0) {
|
|
96
|
+
const found = await probe(port);
|
|
97
|
+
if (found === "kiso-web") {
|
|
98
|
+
const url = await addressFrom(home, port);
|
|
99
|
+
if (open)
|
|
100
|
+
openBrowser(url);
|
|
101
|
+
return say(`kiso web is already running at http://127.0.0.1:${port}${open ? "; opened it in your browser" : `; open ${url}`}.`);
|
|
102
|
+
}
|
|
103
|
+
if (found === "other")
|
|
104
|
+
fail(`port ${port} is held by ${holder(port)}. Stop it, or start kiso web on another port with --port.`);
|
|
105
|
+
}
|
|
106
|
+
const host = await startFromEnv({ port }).catch((error) => error.code === "EADDRINUSE" ? fail(`port ${port} is held by ${holder(port)}. Stop it, or start kiso web on another port with --port.`) : Promise.reject(error));
|
|
107
|
+
const record = hostFile(home, host.port);
|
|
108
|
+
writeFileSync(record, JSON.stringify({ pid: process.pid, token: host.token }), { mode: 0o600 });
|
|
109
|
+
chmodSync(record, 0o600);
|
|
110
|
+
const url = `${host.origin}/#c=${host.connectCode()}`;
|
|
111
|
+
process.stdout.write(`READY ${JSON.stringify({ origin: host.origin, port: host.port, pid: process.pid, openRuns: host.service.openRuns(), ...(open ? {} : { connect: url }) })}\n`);
|
|
112
|
+
say(`kiso web is running at ${host.origin} (Ctrl-C stops it).`);
|
|
113
|
+
if (open)
|
|
114
|
+
openBrowser(url);
|
|
115
|
+
else
|
|
116
|
+
say(`Open ${url} (the code in it works once, for two minutes).`);
|
|
117
|
+
let stopping = false;
|
|
118
|
+
const stop = () => {
|
|
119
|
+
if (stopping)
|
|
120
|
+
return;
|
|
121
|
+
stopping = true;
|
|
122
|
+
rmSync(record, { force: true });
|
|
123
|
+
void host.close(5_000).then(() => process.exit(0));
|
|
124
|
+
};
|
|
125
|
+
process.on("SIGTERM", stop);
|
|
126
|
+
process.on("SIGINT", stop);
|
|
127
|
+
}
|
|
128
|
+
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
import { SessionStore } from "@vincemakes/kiso-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Lists one session folder off the host's thread: `SessionStore.list()` reads
|
|
5
|
+
* every log whole to title it, and one folder of long sessions takes seconds.
|
|
6
|
+
* Message in: `{ dir }`. Message out: `{ dir, metas }` or `{ dir, error }`.
|
|
7
|
+
*/
|
|
8
|
+
parentPort.on("message", (message) => {
|
|
9
|
+
try {
|
|
10
|
+
const store = new SessionStore(message.dir);
|
|
11
|
+
const metas = store.list();
|
|
12
|
+
store.closeAll();
|
|
13
|
+
parentPort.postMessage({ dir: message.dir, metas });
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
parentPort.postMessage({ dir: message.dir, error: error instanceof Error ? error.message : String(error) });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { Worker } from "node:worker_threads";
|
|
4
|
+
import { sessionFolders } from "@vincemakes/kiso-code/config";
|
|
5
|
+
/**
|
|
6
|
+
* The history: every Work of every project folder under the kiso home, the
|
|
7
|
+
* terminal's own layout (`projects/<enc>/`). Reading a folder's titles is
|
|
8
|
+
* slow (see history-worker.ts), so it happens in a worker, one folder at a
|
|
9
|
+
* time, the folder in use first, and a folder is read again only when its
|
|
10
|
+
* files changed (count, bytes, newest write) and not more often than
|
|
11
|
+
* `MIN_AGE_MS`. A request answers from what is read so far and says it is
|
|
12
|
+
* still `scanning`.
|
|
13
|
+
*/
|
|
14
|
+
const MIN_AGE_MS = 3_000;
|
|
15
|
+
const MAX_ROWS = 400;
|
|
16
|
+
/** What the host knows about a Work it holds open: a run live, or waiting for an answer. */
|
|
17
|
+
/** A delegated child's parent (the longest id of its folder that `sub-<id>-` starts with: a parent id may have any shape) and its
|
|
18
|
+
* role (what follows the task's number; a role starts with a letter, a hex delegation id may be all digits); nothing for any
|
|
19
|
+
* other session. */
|
|
20
|
+
export function delegated(id, ids) {
|
|
21
|
+
if (!id.startsWith("sub-"))
|
|
22
|
+
return {};
|
|
23
|
+
const parent = ids.filter((p) => p !== id && id.startsWith(`sub-${p}-`)).sort((a, b) => b.length - a.length)[0];
|
|
24
|
+
const role = /-\d+-(\D.*)$/.exec(id.slice(4 + (parent?.length ?? 0)))?.[1];
|
|
25
|
+
return { ...(parent === undefined ? {} : { parent }), ...(role === undefined ? {} : { role }) };
|
|
26
|
+
}
|
|
27
|
+
/** The folder's files as one string: it changes when a log is added or written, or a Work's name file (`<id>.meta.json`, the
|
|
28
|
+
* runtime's titles read it) is written by a rename. */
|
|
29
|
+
function printOf(dir) {
|
|
30
|
+
let count = 0;
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
let newest = 0;
|
|
33
|
+
for (const name of readdirSync(dir)) {
|
|
34
|
+
const meta = name.endsWith(".meta.json");
|
|
35
|
+
if (!name.endsWith(".jsonl") && !meta)
|
|
36
|
+
continue;
|
|
37
|
+
try {
|
|
38
|
+
const s = statSync(join(dir, name));
|
|
39
|
+
if (meta) {
|
|
40
|
+
newest = Math.max(newest, s.mtimeMs);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (s.size === 0)
|
|
44
|
+
continue;
|
|
45
|
+
count += 1;
|
|
46
|
+
bytes += s.size;
|
|
47
|
+
newest = Math.max(newest, s.mtimeMs);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// gone between the listing and the stat
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return `${count}:${bytes}:${newest}`;
|
|
54
|
+
}
|
|
55
|
+
export class History {
|
|
56
|
+
#home;
|
|
57
|
+
#live;
|
|
58
|
+
#marksOf;
|
|
59
|
+
#reads = new Map();
|
|
60
|
+
#queue = [];
|
|
61
|
+
#worker = null;
|
|
62
|
+
#inflight = null;
|
|
63
|
+
constructor(home, live, marksOf = () => ({})) {
|
|
64
|
+
this.#home = home;
|
|
65
|
+
this.#live = live;
|
|
66
|
+
this.#marksOf = marksOf;
|
|
67
|
+
}
|
|
68
|
+
/** `fresh` names a folder whose listing is read again now, whatever its age. */
|
|
69
|
+
async snapshot(current, fresh) {
|
|
70
|
+
const rows = [];
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
for (const folder of sessionFolders(this.#home)) {
|
|
73
|
+
if (folder.kind !== "project" || folder.workspace === null)
|
|
74
|
+
continue;
|
|
75
|
+
let print;
|
|
76
|
+
try {
|
|
77
|
+
print = printOf(folder.dir);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const read = this.#reads.get(folder.dir);
|
|
83
|
+
const due = read === undefined || (read.print !== print && (now - read.at >= MIN_AGE_MS || fresh === basename(folder.dir)));
|
|
84
|
+
if (due)
|
|
85
|
+
this.#enqueue(folder.dir, folder.dir === current.dir);
|
|
86
|
+
if (read === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
const key = basename(folder.dir);
|
|
89
|
+
const ids = read.metas.map((m) => m.id);
|
|
90
|
+
for (const meta of read.metas) {
|
|
91
|
+
rows.push({
|
|
92
|
+
// a fork's parent: the store's own record (any app made it), else the host's (forks it made before)
|
|
93
|
+
...(meta.forkedFrom !== undefined ? { parent: meta.forkedFrom } : {}),
|
|
94
|
+
...this.#marksOf(meta.id),
|
|
95
|
+
...delegated(meta.id, ids),
|
|
96
|
+
id: meta.id,
|
|
97
|
+
key,
|
|
98
|
+
folder: basename(folder.workspace),
|
|
99
|
+
title: meta.title,
|
|
100
|
+
createdAt: meta.createdAt,
|
|
101
|
+
updatedAt: meta.updatedAt,
|
|
102
|
+
events: meta.events,
|
|
103
|
+
runs: meta.runs,
|
|
104
|
+
state: "idle",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
rows.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
109
|
+
const works = await Promise.all(rows.slice(0, MAX_ROWS).map(async (row) => ({ ...row, state: await this.#live(row.key, row.id) })));
|
|
110
|
+
return { current: { key: current.key, folder: basename(current.dir) }, works, scanning: this.#inflight !== null || this.#queue.length > 0 };
|
|
111
|
+
}
|
|
112
|
+
#enqueue(dir, first) {
|
|
113
|
+
if (this.#inflight === dir || this.#queue.includes(dir))
|
|
114
|
+
return;
|
|
115
|
+
if (first)
|
|
116
|
+
this.#queue.unshift(dir);
|
|
117
|
+
else
|
|
118
|
+
this.#queue.push(dir);
|
|
119
|
+
this.#pump();
|
|
120
|
+
}
|
|
121
|
+
#pump() {
|
|
122
|
+
if (this.#inflight !== null)
|
|
123
|
+
return;
|
|
124
|
+
const dir = this.#queue.shift();
|
|
125
|
+
if (dir === undefined)
|
|
126
|
+
return;
|
|
127
|
+
this.#inflight = dir;
|
|
128
|
+
const print = printOf(dir);
|
|
129
|
+
const worker = (this.#worker ??= new Worker(new URL("./history-worker.js", import.meta.url)));
|
|
130
|
+
// both listeners go when either answers: one read, one pair (the worker lives across reads, so a kept listener piles up)
|
|
131
|
+
const done = (message) => {
|
|
132
|
+
worker.off("message", done);
|
|
133
|
+
worker.off("error", failed);
|
|
134
|
+
// an unreadable folder is remembered empty: it is tried again when its files change
|
|
135
|
+
this.#reads.set(dir, { print, metas: message.metas ?? [], at: Date.now() });
|
|
136
|
+
this.#inflight = null;
|
|
137
|
+
this.#pump();
|
|
138
|
+
};
|
|
139
|
+
const failed = (error) => {
|
|
140
|
+
this.#worker = null;
|
|
141
|
+
done({ dir, error: error.message });
|
|
142
|
+
};
|
|
143
|
+
worker.on("message", done);
|
|
144
|
+
worker.on("error", failed);
|
|
145
|
+
worker.postMessage({ dir });
|
|
146
|
+
}
|
|
147
|
+
close() {
|
|
148
|
+
void this.#worker?.terminate();
|
|
149
|
+
this.#worker = null;
|
|
150
|
+
}
|
|
151
|
+
}
|