@someonegg/codex-hub 0.1.9 → 0.2.1
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/README.md +10 -2
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/index-BQRrfYzk.js +306 -0
- package/dist/client/assets/{index-BO1xw5sL.css → index-CODhVb8z.css} +1 -1
- package/dist/client/favicon.ico +0 -0
- package/dist/client/favicon.svg +1 -1
- package/dist/client/icons/icon-192.png +0 -0
- package/dist/client/icons/icon-512.png +0 -0
- package/dist/client/icons/icon-maskable-512.png +0 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/config.js +25 -3
- package/dist/server/server/hosting/diff-store.js +102 -0
- package/dist/server/server/hosting/lock.js +65 -0
- package/dist/server/server/hosting/routes.js +152 -0
- package/dist/server/server/hosting/store.js +158 -0
- package/dist/server/server/http/create-server.js +39 -6
- package/dist/server/server/http/router.js +1 -1
- package/dist/server/server/main.js +55 -0
- package/dist/server/shared/diff.js +158 -0
- package/package.json +3 -2
- package/dist/client/assets/index-BM0ywt3r.js +0 -304
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { sendJson } from "../http/router.js";
|
|
5
|
+
import { DiffStore } from "./diff-store.js";
|
|
6
|
+
import { HostingStore } from "./store.js";
|
|
7
|
+
const types = {
|
|
8
|
+
".html": "text/html; charset=utf-8",
|
|
9
|
+
".css": "text/css; charset=utf-8",
|
|
10
|
+
".js": "text/javascript; charset=utf-8",
|
|
11
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
12
|
+
".json": "application/json",
|
|
13
|
+
".svg": "image/svg+xml",
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".webp": "image/webp",
|
|
19
|
+
".avif": "image/avif",
|
|
20
|
+
".ico": "image/x-icon",
|
|
21
|
+
".woff": "font/woff",
|
|
22
|
+
".woff2": "font/woff2",
|
|
23
|
+
".ttf": "font/ttf",
|
|
24
|
+
".otf": "font/otf",
|
|
25
|
+
".wasm": "application/wasm",
|
|
26
|
+
".xml": "application/xml",
|
|
27
|
+
".pdf": "application/pdf",
|
|
28
|
+
".webmanifest": "application/manifest+json",
|
|
29
|
+
".wav": "audio/wav",
|
|
30
|
+
".txt": "text/plain; charset=utf-8",
|
|
31
|
+
".mp4": "video/mp4",
|
|
32
|
+
".webm": "video/webm",
|
|
33
|
+
".mp3": "audio/mpeg",
|
|
34
|
+
};
|
|
35
|
+
export function hostingRoutes(directory) {
|
|
36
|
+
const store = directory === undefined ? undefined : new HostingStore(directory);
|
|
37
|
+
const diffs = directory === undefined ? undefined : new DiffStore(directory);
|
|
38
|
+
return async (request, response) => {
|
|
39
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
40
|
+
const path = url.pathname;
|
|
41
|
+
const head = request.method === "HEAD";
|
|
42
|
+
if (path === "/api/v1/capabilities" && request.method === "GET") {
|
|
43
|
+
sendJson(response, 200, { hosting: !!store });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (path.startsWith("/api/v1/diffs")) {
|
|
47
|
+
response.setHeader("Cache-Control", "private, no-store");
|
|
48
|
+
const match = /^\/api\/v1\/diffs\/([^/]+)$/.exec(path);
|
|
49
|
+
const entry = diffs && match && request.method === "GET" ? await diffs.get(match[1]) : null;
|
|
50
|
+
if (entry) {
|
|
51
|
+
sendJson(response, 200, entry);
|
|
52
|
+
}
|
|
53
|
+
else if (diffs && match && request.method === "DELETE" && (await diffs.exists(match[1]))) {
|
|
54
|
+
await diffs.delete(match[1]);
|
|
55
|
+
response.statusCode = 204;
|
|
56
|
+
response.end();
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
sendJson(response, 404, {
|
|
60
|
+
error: { code: "not_found", message: "Diff not found or already cleaned up" },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (!path.startsWith("/web/") && !path.startsWith("/api/v1/artifacts"))
|
|
66
|
+
return false;
|
|
67
|
+
const missing = () => sendJson(response, 404, { error: { code: "not_found", message: "Artifact not found" } }, head);
|
|
68
|
+
if (!store) {
|
|
69
|
+
missing();
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (path === "/api/v1/artifacts" && request.method === "GET") {
|
|
73
|
+
const groups = new Map();
|
|
74
|
+
for (const { artifact } of await store.entries()) {
|
|
75
|
+
const list = groups.get(artifact.project) ?? [];
|
|
76
|
+
list.push(artifact);
|
|
77
|
+
groups.set(artifact.project, list);
|
|
78
|
+
}
|
|
79
|
+
sendJson(response, 200, {
|
|
80
|
+
projects: [...groups].map(([directory, artifacts]) => ({ directory, artifacts })),
|
|
81
|
+
});
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
const deletion = /^\/api\/v1\/artifacts\/([a-f0-9]{32})$/.exec(path);
|
|
85
|
+
if (deletion && request.method === "DELETE") {
|
|
86
|
+
if (await store.delete(deletion[1])) {
|
|
87
|
+
response.statusCode = 204;
|
|
88
|
+
response.end();
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
missing();
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const match = /^\/web\/([a-f0-9]{32})\/(?:([a-f0-9]{32})\/(.*))?$/.exec(path);
|
|
95
|
+
if (!match || (request.method !== "GET" && !head)) {
|
|
96
|
+
missing();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
response.removeHeader("Content-Security-Policy");
|
|
100
|
+
response.setHeader("Cache-Control", "private, no-store");
|
|
101
|
+
const entry = await store.get(match[1]);
|
|
102
|
+
if (!entry) {
|
|
103
|
+
missing();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (!match[2]) {
|
|
107
|
+
response.statusCode = 302;
|
|
108
|
+
response.setHeader("Location", `/web/${entry.artifact.id}/${entry.artifact.snapshotId}/${url.search}`);
|
|
109
|
+
response.end();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const relative = decodeURIComponent(match[3]);
|
|
114
|
+
if (relative.split(/[\\/]/).some((part) => part === "..") || relative.includes("\0"))
|
|
115
|
+
throw new Error("Invalid path");
|
|
116
|
+
const snapshotRoot = resolve(entry.directory, "snapshots", match[2]);
|
|
117
|
+
const root = await realpath(snapshotRoot);
|
|
118
|
+
if (root !== snapshotRoot)
|
|
119
|
+
throw new Error("Snapshot aliases are not allowed");
|
|
120
|
+
let file = await realpath(resolve(root, relative || "index.html"));
|
|
121
|
+
if (!file.startsWith(root + sep))
|
|
122
|
+
throw new Error("Invalid path");
|
|
123
|
+
if ((await stat(file)).isDirectory()) {
|
|
124
|
+
if (!path.endsWith("/")) {
|
|
125
|
+
response.statusCode = 302;
|
|
126
|
+
response.setHeader("Location", path + "/" + url.search);
|
|
127
|
+
response.end();
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
file = await realpath(join(file, "index.html"));
|
|
131
|
+
}
|
|
132
|
+
if (!file.startsWith(root + sep))
|
|
133
|
+
throw new Error("Invalid path");
|
|
134
|
+
const info = await stat(file);
|
|
135
|
+
if (!info.isFile())
|
|
136
|
+
throw new Error("Not a file");
|
|
137
|
+
response.setHeader("Content-Type", types[extname(file).toLowerCase()] ?? "application/octet-stream");
|
|
138
|
+
response.setHeader("Content-Length", info.size);
|
|
139
|
+
if (head)
|
|
140
|
+
response.end();
|
|
141
|
+
else
|
|
142
|
+
createReadStream(file)
|
|
143
|
+
.on("error", () => response.destroy())
|
|
144
|
+
.pipe(response);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
missing();
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { copyFile, lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
6
|
+
import { HostingLock } from "./lock.js";
|
|
7
|
+
export const ID = /^[a-f0-9]{32}$/;
|
|
8
|
+
export const defaultHostingDirectory = join(homedir(), ".codex-hub", "hosting");
|
|
9
|
+
function parseArtifact(json) {
|
|
10
|
+
const value = JSON.parse(json);
|
|
11
|
+
if (!value ||
|
|
12
|
+
!ID.test(value.id) ||
|
|
13
|
+
!ID.test(value.snapshotId) ||
|
|
14
|
+
typeof value.project !== "string" ||
|
|
15
|
+
typeof value.source !== "string" ||
|
|
16
|
+
typeof value.name !== "string" ||
|
|
17
|
+
typeof value.title !== "string" ||
|
|
18
|
+
!Number.isFinite(Date.parse(value.createdAt)) ||
|
|
19
|
+
!Number.isFinite(Date.parse(value.updatedAt)) ||
|
|
20
|
+
value.url !== `/web/${value.id}/`)
|
|
21
|
+
throw new Error("Invalid artifact metadata");
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
async function copySnapshot(from, to) {
|
|
25
|
+
const info = await lstat(from);
|
|
26
|
+
if (info.isDirectory()) {
|
|
27
|
+
await mkdir(to);
|
|
28
|
+
for (const item of await readdir(from)) {
|
|
29
|
+
await copySnapshot(join(from, item), join(to, item));
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!info.isFile())
|
|
34
|
+
throw new Error(`Only regular files and directories can be published: ${from}`);
|
|
35
|
+
await copyFile(from, to, constants.COPYFILE_EXCL);
|
|
36
|
+
}
|
|
37
|
+
function inside(child, parent) {
|
|
38
|
+
return child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
39
|
+
}
|
|
40
|
+
export class HostingStore {
|
|
41
|
+
root;
|
|
42
|
+
constructor(root = defaultHostingDirectory) {
|
|
43
|
+
if (!root.trim())
|
|
44
|
+
throw new Error("Hosting directory must not be empty");
|
|
45
|
+
this.root = resolve(root.startsWith("~/") ? join(homedir(), root.slice(2)) : root);
|
|
46
|
+
}
|
|
47
|
+
async locked(action) {
|
|
48
|
+
const lock = new HostingLock(this.root, ".write-lock");
|
|
49
|
+
try {
|
|
50
|
+
while (!lock.acquire())
|
|
51
|
+
await new Promise((done) => setTimeout(done, 30));
|
|
52
|
+
return await action();
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
lock.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async get(id) {
|
|
59
|
+
if (!ID.test(id))
|
|
60
|
+
return undefined;
|
|
61
|
+
const directory = join(this.root, "artifacts", id);
|
|
62
|
+
let directoryExists = false;
|
|
63
|
+
try {
|
|
64
|
+
const canonicalDirectory = join(await realpath(this.root), "artifacts", id);
|
|
65
|
+
if ((await realpath(directory)) !== canonicalDirectory)
|
|
66
|
+
throw new Error("Artifact aliases are not allowed");
|
|
67
|
+
if (!(await lstat(directory)).isDirectory())
|
|
68
|
+
throw new Error("Invalid artifact directory");
|
|
69
|
+
directoryExists = true;
|
|
70
|
+
const artifact = parseArtifact(await readFile(join(directory, "artifact.json"), "utf8"));
|
|
71
|
+
if (artifact.id !== id)
|
|
72
|
+
throw new Error("Artifact identity does not match directory");
|
|
73
|
+
return { artifact, directory: canonicalDirectory };
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (directoryExists || error.code !== "ENOENT")
|
|
77
|
+
console.warn(`Skipping damaged hosting entry ${directory}: ${String(error)}`);
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async entries() {
|
|
82
|
+
let children;
|
|
83
|
+
try {
|
|
84
|
+
children = await readdir(join(this.root, "artifacts"), { withFileTypes: true });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.code === "ENOENT")
|
|
88
|
+
return [];
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const results = [];
|
|
92
|
+
for (const child of children) {
|
|
93
|
+
if (!child.isDirectory() || !ID.test(child.name))
|
|
94
|
+
continue;
|
|
95
|
+
const entry = await this.get(child.name);
|
|
96
|
+
if (entry)
|
|
97
|
+
results.push(entry);
|
|
98
|
+
}
|
|
99
|
+
return results;
|
|
100
|
+
}
|
|
101
|
+
async publish(sourcePath, projectPathInput, nameInput, title) {
|
|
102
|
+
const source = await realpath(resolve(sourcePath));
|
|
103
|
+
const project = await realpath(resolve(projectPathInput));
|
|
104
|
+
if (!(await lstat(source)).isDirectory() || !(await lstat(project)).isDirectory())
|
|
105
|
+
throw new Error("Source and project must be directories");
|
|
106
|
+
await mkdir(this.root, { recursive: true });
|
|
107
|
+
const root = await realpath(this.root);
|
|
108
|
+
if (inside(source, root) || inside(root, source))
|
|
109
|
+
throw new Error("Source must not overlap hosting storage");
|
|
110
|
+
const name = nameInput ?? basename(source);
|
|
111
|
+
if (!name.trim())
|
|
112
|
+
throw new Error("Artifact name must not be empty");
|
|
113
|
+
if (!(await lstat(join(source, "index.html"))).isFile())
|
|
114
|
+
throw new Error("Source requires index.html");
|
|
115
|
+
return this.locked(async () => {
|
|
116
|
+
const previous = (await this.entries()).find(({ artifact }) => artifact.project === project && artifact.name === name)?.artifact;
|
|
117
|
+
const id = previous?.id ?? randomUUID().replaceAll("-", "");
|
|
118
|
+
const directory = join(root, "artifacts", id);
|
|
119
|
+
await mkdir(join(directory, "snapshots"), { recursive: true });
|
|
120
|
+
const snapshotId = randomUUID().replaceAll("-", "");
|
|
121
|
+
const temp = join(directory, "snapshots", `.tmp-${snapshotId}`);
|
|
122
|
+
const metadataTemp = join(directory, `.artifact-${snapshotId}.json`);
|
|
123
|
+
try {
|
|
124
|
+
await copySnapshot(source, temp);
|
|
125
|
+
await rename(temp, join(directory, "snapshots", snapshotId));
|
|
126
|
+
const now = new Date().toISOString();
|
|
127
|
+
const artifact = {
|
|
128
|
+
id,
|
|
129
|
+
name,
|
|
130
|
+
title: title ?? previous?.title ?? name,
|
|
131
|
+
project,
|
|
132
|
+
source,
|
|
133
|
+
snapshotId,
|
|
134
|
+
createdAt: previous?.createdAt ?? now,
|
|
135
|
+
updatedAt: now,
|
|
136
|
+
url: `/web/${id}/`,
|
|
137
|
+
};
|
|
138
|
+
await writeFile(metadataTemp, JSON.stringify(artifact));
|
|
139
|
+
await rename(metadataTemp, join(directory, "artifact.json"));
|
|
140
|
+
return artifact;
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await rm(temp, { recursive: true, force: true });
|
|
144
|
+
await rm(metadataTemp, { force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async delete(id) {
|
|
149
|
+
return this.locked(async () => {
|
|
150
|
+
const entry = await this.get(id);
|
|
151
|
+
if (!entry)
|
|
152
|
+
return false;
|
|
153
|
+
await rm(entry.directory, { recursive: true });
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -3,6 +3,9 @@ import { realpath, stat } from "node:fs/promises";
|
|
|
3
3
|
import { createServer as createHttpServer, } from "node:http";
|
|
4
4
|
import { createServer as createHttpsServer } from "node:https";
|
|
5
5
|
import { extname, resolve, sep } from "node:path";
|
|
6
|
+
import { DiffStore } from "../hosting/diff-store.js";
|
|
7
|
+
import { HostingLock } from "../hosting/lock.js";
|
|
8
|
+
import { hostingRoutes } from "../hosting/routes.js";
|
|
6
9
|
import { emptyApiRouter, sendJson } from "./router.js";
|
|
7
10
|
import { applySecurityHeaders } from "./security.js";
|
|
8
11
|
const CONTENT_TYPES = {
|
|
@@ -21,12 +24,12 @@ function sendText(response, status, message, headOnly) {
|
|
|
21
24
|
response.setHeader("Content-Length", Buffer.byteLength(message));
|
|
22
25
|
response.end(headOnly ? undefined : message);
|
|
23
26
|
}
|
|
24
|
-
async function serveFile(response, filePath, headOnly) {
|
|
27
|
+
async function serveFile(response, filePath, headOnly, status = 200) {
|
|
25
28
|
try {
|
|
26
29
|
const info = await stat(filePath);
|
|
27
30
|
if (!info.isFile())
|
|
28
31
|
return false;
|
|
29
|
-
response.statusCode =
|
|
32
|
+
response.statusCode = status;
|
|
30
33
|
response.setHeader("Content-Type", CONTENT_TYPES[extname(filePath)] ?? "application/octet-stream");
|
|
31
34
|
response.setHeader("Content-Length", info.size);
|
|
32
35
|
if (headOnly) {
|
|
@@ -45,11 +48,34 @@ async function serveFile(response, filePath, headOnly) {
|
|
|
45
48
|
}
|
|
46
49
|
export function createServer(config, apiRouter = emptyApiRouter, closeServices = () => { }, upgrades) {
|
|
47
50
|
const clientRoot = realpathSync(config.clientDirectory);
|
|
51
|
+
const finish = (server) => {
|
|
52
|
+
const lock = config.hostingDirectory
|
|
53
|
+
? new HostingLock(config.hostingDirectory, ".server-lock")
|
|
54
|
+
: undefined;
|
|
55
|
+
try {
|
|
56
|
+
if (lock && !lock.acquire())
|
|
57
|
+
throw new Error("Hosting directory is already used by another Hub server");
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
lock?.close();
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
server.once("close", () => lock?.close());
|
|
64
|
+
server.once("error", () => {
|
|
65
|
+
if (!server.listening)
|
|
66
|
+
lock?.close();
|
|
67
|
+
});
|
|
68
|
+
return attachLifecycle(server, closeServices, upgrades);
|
|
69
|
+
};
|
|
70
|
+
const hosting = hostingRoutes(config.hostingDirectory);
|
|
71
|
+
const diffs = config.hostingDirectory ? new DiffStore(config.hostingDirectory) : undefined;
|
|
48
72
|
const clientRootPrefix = `${clientRoot}${sep}`;
|
|
49
73
|
const requestListener = async (request, response) => {
|
|
50
74
|
applySecurityHeaders(response, request);
|
|
51
75
|
const headOnly = request.method === "HEAD";
|
|
52
76
|
try {
|
|
77
|
+
if (await hosting(request, response))
|
|
78
|
+
return;
|
|
53
79
|
if (await apiRouter(request, response))
|
|
54
80
|
return;
|
|
55
81
|
if (request.method !== "GET" && !headOnly) {
|
|
@@ -62,7 +88,14 @@ export function createServer(config, apiRouter = emptyApiRouter, closeServices =
|
|
|
62
88
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
63
89
|
const decodedPath = decodeURIComponent(url.pathname);
|
|
64
90
|
const sessionPage = /^\/sessions\/[A-Za-z0-9_-]{20,100}\/?$/.test(decodedPath);
|
|
65
|
-
const
|
|
91
|
+
const diffPage = /^\/diffs\/([a-f0-9-]{36})$/.exec(decodedPath);
|
|
92
|
+
const appPage = decodedPath === "/" ||
|
|
93
|
+
sessionPage ||
|
|
94
|
+
((decodedPath === "/artifacts" || diffPage) && config.hostingDirectory);
|
|
95
|
+
const pageStatus = diffPage && diffs && !(await diffs.exists(diffPage[1])) ? 404 : 200;
|
|
96
|
+
if (diffPage)
|
|
97
|
+
response.setHeader("Cache-Control", "private, no-store");
|
|
98
|
+
const relativePath = appPage ? "index.html" : decodedPath.slice(1);
|
|
66
99
|
const requestedPath = resolve(clientRoot, relativePath);
|
|
67
100
|
let canonicalPath;
|
|
68
101
|
try {
|
|
@@ -73,7 +106,7 @@ export function createServer(config, apiRouter = emptyApiRouter, closeServices =
|
|
|
73
106
|
return;
|
|
74
107
|
}
|
|
75
108
|
if (canonicalPath.startsWith(clientRootPrefix) &&
|
|
76
|
-
(await serveFile(response, canonicalPath, headOnly))) {
|
|
109
|
+
(await serveFile(response, canonicalPath, headOnly, pageStatus))) {
|
|
77
110
|
return;
|
|
78
111
|
}
|
|
79
112
|
sendText(response, 404, "Not found", headOnly);
|
|
@@ -84,7 +117,7 @@ export function createServer(config, apiRouter = emptyApiRouter, closeServices =
|
|
|
84
117
|
};
|
|
85
118
|
if (!config.tls.enabled) {
|
|
86
119
|
const server = createHttpServer(requestListener);
|
|
87
|
-
return
|
|
120
|
+
return finish(server);
|
|
88
121
|
}
|
|
89
122
|
const requireClientCertificate = config.tls.certificateAuthorityPath !== undefined;
|
|
90
123
|
const server = createHttpsServer({
|
|
@@ -97,7 +130,7 @@ export function createServer(config, apiRouter = emptyApiRouter, closeServices =
|
|
|
97
130
|
requestCert: requireClientCertificate,
|
|
98
131
|
rejectUnauthorized: requireClientCertificate,
|
|
99
132
|
}, requestListener);
|
|
100
|
-
return
|
|
133
|
+
return finish(server);
|
|
101
134
|
}
|
|
102
135
|
/** Own startup cleanup as well as the server's pre-drain shutdown hook. */
|
|
103
136
|
export async function startServer(config, apiRouter, closeServices, upgrades) {
|
|
@@ -2,7 +2,7 @@ export function sendJson(response, status, body, headOnly = false) {
|
|
|
2
2
|
const json = JSON.stringify(body);
|
|
3
3
|
response.statusCode = status;
|
|
4
4
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
5
|
-
response.setHeader("Cache-Control", "no-store");
|
|
5
|
+
response.setHeader("Cache-Control", response.getHeader("Cache-Control") ?? "no-store");
|
|
6
6
|
response.setHeader("Content-Length", Buffer.byteLength(json));
|
|
7
7
|
response.end(headOnly ? undefined : json);
|
|
8
8
|
}
|
|
@@ -1,11 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
2
3
|
import { commandLineHelp, loadConfig } from "./config.js";
|
|
3
4
|
import { createCodexHub } from "./create-hub.js";
|
|
5
|
+
import { DiffStore } from "./hosting/diff-store.js";
|
|
6
|
+
import { HostingStore } from "./hosting/store.js";
|
|
4
7
|
import { createApiRouter } from "./http/api-router.js";
|
|
5
8
|
import { startServer } from "./http/create-server.js";
|
|
6
9
|
import { createTerminalPreviewWebSocket } from "./http/terminal-preview-websocket.js";
|
|
7
10
|
import { uploadDirectory } from "./http/upload-routes.js";
|
|
11
|
+
async function runResourceCommand(resource, action, args) {
|
|
12
|
+
const { values, positionals } = parseArgs({
|
|
13
|
+
args,
|
|
14
|
+
allowPositionals: true,
|
|
15
|
+
options: {
|
|
16
|
+
project: { type: "string" },
|
|
17
|
+
name: { type: "string" },
|
|
18
|
+
title: { type: "string" },
|
|
19
|
+
"hosting-dir": { type: "string" },
|
|
20
|
+
json: { type: "boolean" },
|
|
21
|
+
help: { type: "boolean" },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
if (values.help || action === "--help") {
|
|
25
|
+
console.log(commandLineHelp());
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!["publish", "list", "delete"].includes(action) ||
|
|
29
|
+
positionals.length !== (action === "list" ? 0 : 1))
|
|
30
|
+
throw new Error("Expected page/diff publish <source>, list, or delete <id>");
|
|
31
|
+
const pages = new HostingStore(values["hosting-dir"]);
|
|
32
|
+
const diffs = new DiffStore(values["hosting-dir"]);
|
|
33
|
+
let result;
|
|
34
|
+
if (action === "publish") {
|
|
35
|
+
if (resource === "page" && !values.project)
|
|
36
|
+
throw new Error("page publish requires --project");
|
|
37
|
+
const entry = resource === "page"
|
|
38
|
+
? await pages.publish(positionals[0], values.project, values.name, values.title)
|
|
39
|
+
: await diffs.publish(positionals[0], values.title);
|
|
40
|
+
const title = entry.title.replace(/[\\[\]]/g, "\\$&").replace(/[\r\n]/g, " ");
|
|
41
|
+
console.log(values.json ? JSON.stringify(entry) : `${entry.url}\n[${title}](${entry.url})`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (action === "list") {
|
|
45
|
+
result =
|
|
46
|
+
resource === "page"
|
|
47
|
+
? (await pages.entries()).map(({ artifact }) => artifact)
|
|
48
|
+
: await diffs.list();
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
if (resource === "page")
|
|
52
|
+
await pages.delete(positionals[0]);
|
|
53
|
+
else
|
|
54
|
+
await diffs.delete(positionals[0]);
|
|
55
|
+
result = { id: positionals[0], deleted: true };
|
|
56
|
+
}
|
|
57
|
+
console.log(JSON.stringify(result, null, values.json ? undefined : 2));
|
|
58
|
+
}
|
|
8
59
|
async function main() {
|
|
60
|
+
if (["page", "diff"].includes(process.argv[2])) {
|
|
61
|
+
await runResourceCommand(process.argv[2], process.argv[3], process.argv.slice(4));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
9
64
|
const command = loadConfig();
|
|
10
65
|
if (command.kind === "help") {
|
|
11
66
|
console.log(commandLineHelp());
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const pathEscapes = {
|
|
2
|
+
a: "\x07",
|
|
3
|
+
b: "\b",
|
|
4
|
+
f: "\f",
|
|
5
|
+
v: "\v",
|
|
6
|
+
n: "\n",
|
|
7
|
+
t: "\t",
|
|
8
|
+
r: "\r",
|
|
9
|
+
};
|
|
10
|
+
/** Git quotes paths as C strings, including octal-escaped UTF-8 bytes. */
|
|
11
|
+
function decodePath(value) {
|
|
12
|
+
if (!value.startsWith('"'))
|
|
13
|
+
return value;
|
|
14
|
+
const bytes = [];
|
|
15
|
+
const inner = value.slice(1, value.lastIndexOf('"'));
|
|
16
|
+
for (let i = 0; i < inner.length; i++) {
|
|
17
|
+
if (inner[i] === "\\") {
|
|
18
|
+
const octal = /^[0-7]{1,3}/.exec(inner.slice(i + 1));
|
|
19
|
+
if (octal) {
|
|
20
|
+
bytes.push(parseInt(octal[0], 8));
|
|
21
|
+
i += octal[0].length;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const escape = inner[++i];
|
|
25
|
+
bytes.push(...new TextEncoder().encode(pathEscapes[escape] ?? escape));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const point = String.fromCodePoint(inner.codePointAt(i));
|
|
29
|
+
bytes.push(...new TextEncoder().encode(point));
|
|
30
|
+
i += point.length - 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
34
|
+
}
|
|
35
|
+
function pathName(value) {
|
|
36
|
+
return decodePath(value).replace(/^[ab]\//, "");
|
|
37
|
+
}
|
|
38
|
+
/** Only repeated paths can be inferred reliably without extended headers. */
|
|
39
|
+
function headerPath(value) {
|
|
40
|
+
const quoted = /^"(?:[^"\\]|\\.)*" /.exec(value);
|
|
41
|
+
let oldPath;
|
|
42
|
+
let newPath;
|
|
43
|
+
if (quoted) {
|
|
44
|
+
oldPath = value.slice(0, quoted[0].length - 1);
|
|
45
|
+
newPath = value.slice(quoted[0].length);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const quotedNew = / ("(?:[^"\\]|\\.)*")$/.exec(value);
|
|
49
|
+
// Unquoted, identical paths have equal lengths, regardless of embedded spaces.
|
|
50
|
+
const separator = quotedNew?.index ?? (value.length - 1) / 2;
|
|
51
|
+
if (!Number.isInteger(separator) || value[separator] !== " ")
|
|
52
|
+
return null;
|
|
53
|
+
oldPath = value.slice(0, separator);
|
|
54
|
+
newPath = value.slice(separator + 1);
|
|
55
|
+
}
|
|
56
|
+
const oldName = decodePath(oldPath);
|
|
57
|
+
const newName = decodePath(newPath);
|
|
58
|
+
return oldName.startsWith("a/") &&
|
|
59
|
+
newName.startsWith("b/") &&
|
|
60
|
+
oldName.slice(2) === newName.slice(2)
|
|
61
|
+
? newName.slice(2)
|
|
62
|
+
: null;
|
|
63
|
+
}
|
|
64
|
+
export function parseDiff(raw) {
|
|
65
|
+
const files = [];
|
|
66
|
+
let file;
|
|
67
|
+
let pathResolved = false;
|
|
68
|
+
let oldLine = 0;
|
|
69
|
+
let newLine = 0;
|
|
70
|
+
let oldRemaining = 0;
|
|
71
|
+
let newRemaining = 0;
|
|
72
|
+
let inHunk = false;
|
|
73
|
+
let warning = false;
|
|
74
|
+
const finishHunk = () => {
|
|
75
|
+
if (oldRemaining || newRemaining)
|
|
76
|
+
warning = true;
|
|
77
|
+
inHunk = false;
|
|
78
|
+
oldRemaining = newRemaining = 0;
|
|
79
|
+
};
|
|
80
|
+
const finishFile = () => {
|
|
81
|
+
finishHunk();
|
|
82
|
+
if (file && !pathResolved)
|
|
83
|
+
warning = true;
|
|
84
|
+
};
|
|
85
|
+
const lines = raw.split("\n");
|
|
86
|
+
if (lines.at(-1) === "")
|
|
87
|
+
lines.pop();
|
|
88
|
+
for (const text of lines) {
|
|
89
|
+
if (text.startsWith("diff --git ")) {
|
|
90
|
+
finishFile();
|
|
91
|
+
const path = headerPath(text.slice(11));
|
|
92
|
+
pathResolved = path !== null;
|
|
93
|
+
file = {
|
|
94
|
+
path: path ?? text.slice(11),
|
|
95
|
+
lines: [],
|
|
96
|
+
additions: 0,
|
|
97
|
+
deletions: 0,
|
|
98
|
+
};
|
|
99
|
+
files.push(file);
|
|
100
|
+
}
|
|
101
|
+
if (!file) {
|
|
102
|
+
if (text.trim())
|
|
103
|
+
warning = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(text);
|
|
107
|
+
if (hunk) {
|
|
108
|
+
finishHunk();
|
|
109
|
+
oldLine = Number(hunk[1]);
|
|
110
|
+
newLine = Number(hunk[3]);
|
|
111
|
+
oldRemaining = Number(hunk[2] ?? 1);
|
|
112
|
+
newRemaining = Number(hunk[4] ?? 1);
|
|
113
|
+
inHunk = true;
|
|
114
|
+
file.lines.push({ text, kind: "meta" });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (inHunk && (oldRemaining || newRemaining) && /^[ +\-]/.test(text)) {
|
|
118
|
+
const kind = text[0] === "+" ? "add" : text[0] === "-" ? "remove" : "context";
|
|
119
|
+
const line = { text: text.slice(1), kind };
|
|
120
|
+
if (kind !== "add") {
|
|
121
|
+
line.old = oldLine++;
|
|
122
|
+
oldRemaining--;
|
|
123
|
+
}
|
|
124
|
+
if (kind !== "remove") {
|
|
125
|
+
line.next = newLine++;
|
|
126
|
+
newRemaining--;
|
|
127
|
+
}
|
|
128
|
+
if (oldRemaining < 0 || newRemaining < 0)
|
|
129
|
+
warning = true;
|
|
130
|
+
if (kind === "add")
|
|
131
|
+
file.additions++;
|
|
132
|
+
if (kind === "remove")
|
|
133
|
+
file.deletions++;
|
|
134
|
+
file.lines.push(line);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (/^(\+\+\+|---) /.test(text)) {
|
|
138
|
+
const path = text.slice(4);
|
|
139
|
+
if (path !== "/dev/null") {
|
|
140
|
+
file.path = pathName(path.replace(/\t$/, ""));
|
|
141
|
+
pathResolved = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const renamed = /^(?:rename|copy) to (.*)$/.exec(text);
|
|
145
|
+
if (renamed) {
|
|
146
|
+
file.path = decodePath(renamed[1]);
|
|
147
|
+
pathResolved = true;
|
|
148
|
+
}
|
|
149
|
+
if (!/^(diff --git |index |--- |\+\+\+ |new file mode |deleted file mode |old mode |new mode |similarity index |dissimilarity index |rename from |rename to |copy from |copy to |Binary files .* differ$|\$)/.test(text))
|
|
150
|
+
warning = true;
|
|
151
|
+
if (inHunk && /^[+\- ]/.test(text))
|
|
152
|
+
warning = true;
|
|
153
|
+
file.lines.push({ text, kind: "meta" });
|
|
154
|
+
}
|
|
155
|
+
finishFile();
|
|
156
|
+
return { files, warning: warning || files.length === 0 };
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=diff.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@someonegg/codex-hub",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Local project and session hub for Codex",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"format:check": "prettier --check .",
|
|
21
21
|
"lint": "lint-client",
|
|
22
22
|
"prepack": "npm run build",
|
|
23
|
-
"test:package": "node scripts/test-package.mjs"
|
|
23
|
+
"test:package": "node scripts/test-package.mjs",
|
|
24
|
+
"test:hosting": "npm run build && node --import tsx scripts/test-hosting.mjs"
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
|
26
27
|
"codex",
|