@uplift-beyond/upgo 0.0.0-stage → 0.4.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/dist/server.js ADDED
@@ -0,0 +1,267 @@
1
+ import http from "node:http";
2
+ import { existsSync, readFileSync, statSync, watch } from "node:fs";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { z } from "zod";
6
+ import { applyOp, opSchema } from "./src/core/ops.js";
7
+ import { forget, readRegistry, registryPath, UPGO_DIR } from "./src/core/registry.js";
8
+ import { formatIssues, PHASES, slug } from "./src/core/schema.js";
9
+ import { summarize } from "./src/core/summary.js";
10
+ import { GOALS, readGoals, updateGoals } from "./src/core/store.js";
11
+ import { removeSuggestions, SUGGESTIONS, trySuggestions, updateSuggestions } from "./src/core/suggestions.js";
12
+ import { today, UpgoError } from "./src/core/util.js";
13
+ const HOST = "127.0.0.1";
14
+ const EMPTY_PHASES = Object.fromEntries(PHASES.map((p) => [p, 0]));
15
+ const DEV_PORT = 5173;
16
+ const MAX_BODY = 1_000_000;
17
+ const WATCHED = [GOALS, SUGGESTIONS];
18
+ const dismissSchema = z.strictObject({ dismiss: z.array(slug).min(1) });
19
+ const TYPES = {
20
+ ".html": "text/html; charset=utf-8",
21
+ ".js": "text/javascript; charset=utf-8",
22
+ ".css": "text/css; charset=utf-8",
23
+ ".svg": "image/svg+xml",
24
+ ".woff2": "font/woff2",
25
+ ".json": "application/json",
26
+ ".txt": "text/plain; charset=utf-8",
27
+ ".png": "image/png",
28
+ ".ico": "image/x-icon",
29
+ };
30
+ const SECURITY_HEADERS = {
31
+ "X-Content-Type-Options": "nosniff",
32
+ "Content-Security-Policy": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'",
33
+ };
34
+ const inside = (root, path) => {
35
+ const rel = relative(root, path);
36
+ return rel !== "" && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
37
+ };
38
+ function send(res, status, body) {
39
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...SECURITY_HEADERS });
40
+ res.end(JSON.stringify(body));
41
+ }
42
+ const fail = (res, status, error, details) => send(res, status, details?.length ? { error, details } : { error });
43
+ function readBody(req) {
44
+ return new Promise((ok, no) => {
45
+ let size = 0;
46
+ const chunks = [];
47
+ // Past the limit the rest is drained, not stored, so the 413 still reaches the client.
48
+ req.on("data", (c) => {
49
+ size += c.length;
50
+ if (size <= MAX_BODY)
51
+ chunks.push(c);
52
+ });
53
+ req.on("end", () => (size > MAX_BODY ? no(new UpgoError("request body too large")) : ok(Buffer.concat(chunks).toString("utf8"))));
54
+ req.on("error", no);
55
+ });
56
+ }
57
+ export async function startServer(opts = {}) {
58
+ const staticDir = resolve(opts.staticDir ?? join(UPGO_DIR, "web", "dist"));
59
+ const clients = new Set();
60
+ const watchers = new Map();
61
+ const timers = new Map();
62
+ let port = opts.port ?? 4141;
63
+ // Only our own host names, so a DNS-rebinding page cannot reach the API. The Vite dev proxy (5173, also
64
+ // every other Vite app's default) only with UPGO_DEV=1.
65
+ const dev = opts.dev ?? process.env.UPGO_DEV === "1";
66
+ const authorities = () => (dev ? [port, DEV_PORT] : [port]).flatMap((p) => [`127.0.0.1:${p}`, `localhost:${p}`]);
67
+ const broadcast = (event, data) => {
68
+ for (const res of clients)
69
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
70
+ };
71
+ const debounce = (key, fn) => {
72
+ clearTimeout(timers.get(key));
73
+ timers.set(key, setTimeout(fn, 100));
74
+ };
75
+ const syncWatchers = () => {
76
+ let entries;
77
+ try {
78
+ entries = readRegistry();
79
+ }
80
+ catch {
81
+ entries = [];
82
+ }
83
+ const wanted = new Map(entries.map((e) => [e.id, e.path]));
84
+ for (const [id, w] of watchers) {
85
+ if (!wanted.has(id)) {
86
+ w.close();
87
+ watchers.delete(id);
88
+ }
89
+ }
90
+ for (const [id, path] of wanted) {
91
+ if (watchers.has(id) || !existsSync(path))
92
+ continue;
93
+ try {
94
+ const w = watch(path, (_event, filename) => {
95
+ if (filename && WATCHED.includes(basename(filename.toString())))
96
+ debounce(`p:${id}`, () => broadcast("project", { id }));
97
+ });
98
+ w.on("error", () => watchers.delete(id));
99
+ watchers.set(id, w);
100
+ }
101
+ catch {
102
+ // An unwatchable folder still works, it just is not live.
103
+ }
104
+ }
105
+ };
106
+ const regFile = registryPath();
107
+ const regWatcher = existsSync(dirname(regFile))
108
+ ? watch(dirname(regFile), (_event, filename) => {
109
+ if (filename && basename(filename.toString()) === basename(regFile)) {
110
+ debounce("registry", () => {
111
+ syncWatchers();
112
+ broadcast("projects", {});
113
+ });
114
+ }
115
+ })
116
+ : null;
117
+ syncWatchers();
118
+ const entryFor = (id) => readRegistry().find((e) => e.id === id);
119
+ // Writes need our header and, when the browser sends one, our own origin.
120
+ const refusal = (req) => {
121
+ if (req.headers["x-upgo"] !== "1")
122
+ return "missing X-Upgo header";
123
+ const origin = req.headers.origin;
124
+ if (origin && !authorities().some((a) => origin === `http://${a}`))
125
+ return "foreign origin";
126
+ };
127
+ async function api(req, res, parts) {
128
+ const method = req.method ?? "GET";
129
+ if (method === "GET" && parts.length === 1 && parts[0] === "events") {
130
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive", ...SECURITY_HEADERS });
131
+ res.write(": connected\n\n");
132
+ clients.add(res);
133
+ req.on("close", () => clients.delete(res));
134
+ return;
135
+ }
136
+ if (method === "GET" && parts.length === 1 && parts[0] === "projects") {
137
+ const projects = readRegistry().map((e) => {
138
+ try {
139
+ return { id: e.id, path: e.path, ...summarize(readGoals(e.path)) };
140
+ }
141
+ catch (err) {
142
+ return { id: e.id, path: e.path, open: 0, done: 0, dropped: 0, percent: 0, phases: EMPTY_PHASES, now: [], next: [], recent: [], error: err.message };
143
+ }
144
+ });
145
+ return send(res, 200, { projects });
146
+ }
147
+ if (parts[0] !== "projects" || !parts[1])
148
+ return fail(res, 404, "not found");
149
+ const entry = entryFor(parts[1]);
150
+ if (!entry)
151
+ return fail(res, 404, `no registered project \`${parts[1]}\``);
152
+ if (method === "GET" && parts.length === 2)
153
+ return send(res, 200, { id: entry.id, path: entry.path, file: readGoals(entry.path), ...trySuggestions(entry.path) });
154
+ if (method === "GET" && parts.length === 4 && parts[2] === "detail") {
155
+ const item = readGoals(entry.path).items.find((i) => i.id === parts[3]);
156
+ if (!item?.detail)
157
+ return fail(res, 404, "this item has no detail file");
158
+ const path = resolve(entry.path, item.detail);
159
+ if (!inside(entry.path, path) || extname(path).toLowerCase() !== ".md")
160
+ return fail(res, 403, "detail files must be .md files inside the project");
161
+ if (!existsSync(path))
162
+ return fail(res, 404, `${item.detail} does not exist`);
163
+ return send(res, 200, { path: item.detail, text: readFileSync(path, "utf8") });
164
+ }
165
+ if (method === "DELETE" && parts.length === 2) {
166
+ const refused = refusal(req);
167
+ if (refused)
168
+ return fail(res, 403, refused);
169
+ await forget(entry.id);
170
+ return send(res, 200, { id: entry.id });
171
+ }
172
+ if (method === "POST" && parts.length === 3 && (parts[2] === "ops" || parts[2] === "suggestions")) {
173
+ const refused = refusal(req);
174
+ if (refused)
175
+ return fail(res, 403, refused);
176
+ if (!req.headers["content-type"]?.startsWith("application/json"))
177
+ return fail(res, 415, "expected application/json");
178
+ let body;
179
+ try {
180
+ body = JSON.parse(await readBody(req));
181
+ }
182
+ catch (e) {
183
+ return fail(res, e instanceof UpgoError ? 413 : 400, e instanceof UpgoError ? e.message : "body is not valid JSON");
184
+ }
185
+ if (parts[2] === "suggestions") {
186
+ const asked = dismissSchema.safeParse(body);
187
+ if (!asked.success)
188
+ return fail(res, 400, "invalid request", formatIssues(asked.error));
189
+ const suggestions = await updateSuggestions(entry.path, (list) => removeSuggestions(list, asked.data.dismiss));
190
+ return send(res, 200, { suggestions });
191
+ }
192
+ const op = opSchema.safeParse(body);
193
+ if (!op.success)
194
+ return fail(res, 400, "invalid operation", formatIssues(op.error));
195
+ const file = await updateGoals(entry.path, (f) => applyOp(f, op.data, today()));
196
+ return send(res, 200, { file });
197
+ }
198
+ return fail(res, ["GET", "POST", "DELETE"].includes(method) ? 404 : 405, "not found");
199
+ }
200
+ function serveStatic(req, res, pathname) {
201
+ if (req.method !== "GET" && req.method !== "HEAD")
202
+ return fail(res, 405, "method not allowed");
203
+ const index = join(staticDir, "index.html");
204
+ if (!existsSync(index)) {
205
+ res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8", ...SECURITY_HEADERS });
206
+ res.end("upgo dashboard is not built yet: run `npm run build` in the upgo folder.\n");
207
+ return;
208
+ }
209
+ const candidate = resolve(staticDir, `.${decodeURIComponent(pathname)}`);
210
+ const file = inside(staticDir, candidate) && existsSync(candidate) && statSync(candidate).isFile() ? candidate : index;
211
+ res.writeHead(200, {
212
+ "Content-Type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream",
213
+ "Cache-Control": file === index ? "no-store" : "public, max-age=31536000, immutable",
214
+ ...SECURITY_HEADERS,
215
+ });
216
+ res.end(req.method === "HEAD" ? undefined : readFileSync(file));
217
+ }
218
+ const server = http.createServer(async (req, res) => {
219
+ try {
220
+ if (!authorities().includes(req.headers.host ?? ""))
221
+ return fail(res, 403, "unknown host");
222
+ const { pathname } = new URL(req.url ?? "/", "http://upgo");
223
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
224
+ await api(req, res, pathname.split("/").slice(2).filter(Boolean).map(decodeURIComponent));
225
+ }
226
+ else
227
+ serveStatic(req, res, pathname);
228
+ }
229
+ catch (e) {
230
+ if (res.headersSent)
231
+ return void res.end();
232
+ if (e instanceof UpgoError)
233
+ return fail(res, 400, e.message.split("\n")[0], e.details);
234
+ fail(res, 500, e.message);
235
+ }
236
+ });
237
+ await new Promise((ok, no) => {
238
+ server.once("error", no);
239
+ server.listen(port, HOST, () => ok());
240
+ });
241
+ port = server.address().port;
242
+ const heartbeat = setInterval(() => {
243
+ for (const res of clients)
244
+ res.write(": ping\n\n");
245
+ }, 25_000);
246
+ return {
247
+ url: `http://${HOST}:${port}`,
248
+ port,
249
+ close: async () => {
250
+ clearInterval(heartbeat);
251
+ for (const t of timers.values())
252
+ clearTimeout(t);
253
+ for (const w of watchers.values())
254
+ w.close();
255
+ regWatcher?.close();
256
+ for (const res of clients)
257
+ res.end();
258
+ clients.clear();
259
+ server.closeAllConnections();
260
+ await new Promise((ok) => server.close(() => ok()));
261
+ },
262
+ };
263
+ }
264
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
265
+ const { url } = await startServer({ port: Number(process.env.UPGO_PORT) || 4141 });
266
+ process.stdout.write(`upgo dashboard on ${url}\n`);
267
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { readRegistry } from "./registry.js";
4
+ import { render } from "./render.js";
5
+ import { localIssues } from "./rules.js";
6
+ import { GOALS, MD, readGoals } from "./store.js";
7
+ import { trySuggestions } from "./suggestions.js";
8
+ import { UpgoError } from "./util.js";
9
+ const normalize = (s) => s.replace(/\r\n/g, "\n");
10
+ // Everything `upgo check` verifies for one project dir. Empty array = clean.
11
+ export function checkProject(dir, registry = readRegistry()) {
12
+ let file;
13
+ try {
14
+ file = readGoals(dir);
15
+ }
16
+ catch (e) {
17
+ if (e instanceof UpgoError)
18
+ return e.details.length ? e.details : [e.message];
19
+ throw e;
20
+ }
21
+ const issues = localIssues(file);
22
+ const others = new Map();
23
+ const load = (id) => {
24
+ if (!others.has(id)) {
25
+ const entry = registry.find((e) => e.id === id);
26
+ let loaded = null;
27
+ if (entry) {
28
+ try {
29
+ loaded = readGoals(entry.path);
30
+ }
31
+ catch {
32
+ loaded = null;
33
+ }
34
+ }
35
+ others.set(id, loaded);
36
+ }
37
+ return others.get(id);
38
+ };
39
+ for (const item of file.items) {
40
+ for (const ref of item.related ?? []) {
41
+ const [project, id] = ref.split(":");
42
+ const target = project === file.project ? file : load(project);
43
+ if (target === undefined || target === null)
44
+ issues.push(`item \`${item.id}\`: related \`${ref}\` points at a project that is not registered or not readable`);
45
+ else if (!target.items.some((i) => i.id === id))
46
+ issues.push(`item \`${item.id}\`: related \`${ref}\` does not exist`);
47
+ }
48
+ if (item.detail && !existsSync(resolve(dir, item.detail)))
49
+ issues.push(`item \`${item.id}\`: detail file ${item.detail} does not exist`);
50
+ }
51
+ const { suggestionsError } = trySuggestions(dir);
52
+ if (suggestionsError)
53
+ issues.push(suggestionsError);
54
+ const mdPath = join(dir, MD);
55
+ if (!existsSync(mdPath))
56
+ issues.push(`${MD} is missing, run \`upgo render\``);
57
+ else if (normalize(readFileSync(mdPath, "utf8")) !== render(file))
58
+ issues.push(`${MD} is out of date with ${GOALS}, run \`upgo render\``);
59
+ return issues;
60
+ }