@dsh-stack/tweak-share-links 0.0.0-stage → 0.1.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 +4 -2
- package/bin/share.mjs +29 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +37 -0
- package/lib/settings.d.ts +20 -0
- package/lib/settings.js +15 -0
- package/lib/share.d.ts +46 -0
- package/lib/share.js +273 -0
- package/package.json +57 -4
- package/stack.json +10 -0
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tweak-share-links
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Self-hosted read-only session share links: the share route, share settings section, and the dsh share verb.
|
|
4
|
+
|
|
5
|
+
An extension of the `tweaks` settings surface, split out of the bundled `tweaks` package.
|
package/bin/share.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `dsh share <sessionId>` — publish a read-only share link for one session.
|
|
4
|
+
* Interactive (token-gated) sharing is opt-in: pass `--interactive` and the
|
|
5
|
+
* `tweaks.share.allowInteractive` setting must be true. Prints the URL.
|
|
6
|
+
*
|
|
7
|
+
* Usage: dsh share <sessionId> [--interactive] [--advertised-host <host>]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolveHome } from "@dsh-stack/tweaks/home";
|
|
11
|
+
import { generateToken, writeShareToken } from "../lib/share.js";
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const sessionId = args.find((arg) => !arg.startsWith("--"));
|
|
15
|
+
if (sessionId === undefined) {
|
|
16
|
+
console.error("usage: dsh share <sessionId> [--interactive] [--advertised-host <host>]");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const interactive = args.includes("--interactive");
|
|
20
|
+
const hostIndex = args.indexOf("--advertised-host");
|
|
21
|
+
const advertisedHost = hostIndex >= 0 ? args[hostIndex + 1] : undefined;
|
|
22
|
+
|
|
23
|
+
const home = await resolveHome();
|
|
24
|
+
const token = interactive ? await generateToken() : undefined;
|
|
25
|
+
if (token !== undefined) await writeShareToken(home, token);
|
|
26
|
+
const host = advertisedHost ?? "127.0.0.1:3080";
|
|
27
|
+
const base = "/share";
|
|
28
|
+
const url = `http://${host}${base}/${sessionId}${token === undefined ? "" : `?token=${token}`}`;
|
|
29
|
+
console.log(url);
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tweak-share-links`: self-hosted read-only share links — the
|
|
3
|
+
* `<basePath>/<id>` snapshot route on the harness web server plus the
|
|
4
|
+
* `tweaks-share` settings section. Split out of the bundled `tweaks`
|
|
5
|
+
* package; plugs into the tweaks settings surface.
|
|
6
|
+
* @module tweak-share-links
|
|
7
|
+
*/
|
|
8
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
9
|
+
import type z from "@deepseek-ai/schemastery";
|
|
10
|
+
import { type ShareConfig as ShareConfigType } from "./settings.js";
|
|
11
|
+
export { NS_SHARE, ShareConfig } from "./settings.js";
|
|
12
|
+
export type { ShareConfig as ShareConfigType } from "./settings.js";
|
|
13
|
+
export { generateToken, mountShareRoute, writeShareToken } from "./share.js";
|
|
14
|
+
export declare const name = "tweak-share-links";
|
|
15
|
+
export declare const inject: string[];
|
|
16
|
+
/** The share-links extension config: the share section itself. */
|
|
17
|
+
export type Config = ShareConfigType;
|
|
18
|
+
export declare const Config: z<Config>;
|
|
19
|
+
/**
|
|
20
|
+
* apply implementation.
|
|
21
|
+
*
|
|
22
|
+
* The share-links form is the `ShareConfig` this plugin already declares, and its
|
|
23
|
+
* namespace is this plugin's entry id, so there is nothing to install; what this
|
|
24
|
+
* plugin must declare is that it ships its own page for that form. The read-only
|
|
25
|
+
* share route is mounted from the same config, unchanged.
|
|
26
|
+
*
|
|
27
|
+
* @param ctx - the calling plugin's context.
|
|
28
|
+
* @param config - the active profile's projected share-links fields.
|
|
29
|
+
*/
|
|
30
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tweak-share-links`: self-hosted read-only share links — the
|
|
3
|
+
* `<basePath>/<id>` snapshot route on the harness web server plus the
|
|
4
|
+
* `tweaks-share` settings section. Split out of the bundled `tweaks`
|
|
5
|
+
* package; plugs into the tweaks settings surface.
|
|
6
|
+
* @module tweak-share-links
|
|
7
|
+
*/
|
|
8
|
+
import { declareCustomSettingsPage } from "@dsh-stack/plugin-kit";
|
|
9
|
+
import { resolveHome } from "@dsh-stack/tweaks";
|
|
10
|
+
import { mountShareRoute } from "./share.js";
|
|
11
|
+
import { ShareConfig } from "./settings.js";
|
|
12
|
+
export { NS_SHARE, ShareConfig } from "./settings.js";
|
|
13
|
+
export { generateToken, mountShareRoute, writeShareToken } from "./share.js";
|
|
14
|
+
export const name = "tweak-share-links";
|
|
15
|
+
export const inject = [];
|
|
16
|
+
export const Config = ShareConfig;
|
|
17
|
+
/**
|
|
18
|
+
* apply implementation.
|
|
19
|
+
*
|
|
20
|
+
* The share-links form is the `ShareConfig` this plugin already declares, and its
|
|
21
|
+
* namespace is this plugin's entry id, so there is nothing to install; what this
|
|
22
|
+
* plugin must declare is that it ships its own page for that form. The read-only
|
|
23
|
+
* share route is mounted from the same config, unchanged.
|
|
24
|
+
*
|
|
25
|
+
* @param ctx - the calling plugin's context.
|
|
26
|
+
* @param config - the active profile's projected share-links fields.
|
|
27
|
+
*/
|
|
28
|
+
export function apply(ctx, config) {
|
|
29
|
+
const share = {
|
|
30
|
+
enabled: config?.enabled ?? true,
|
|
31
|
+
allowInteractive: config?.allowInteractive ?? false,
|
|
32
|
+
advertisedHost: config?.advertisedHost ?? "",
|
|
33
|
+
basePath: config?.basePath ?? "/share",
|
|
34
|
+
};
|
|
35
|
+
declareCustomSettingsPage(ctx);
|
|
36
|
+
mountShareRoute(ctx, resolveHome(), share);
|
|
37
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tweak-share-links settings: the share-links section schema shared between
|
|
3
|
+
* the composition entry, the settings document, and the `dsh share` verb.
|
|
4
|
+
* @module tweak-share-links/settings
|
|
5
|
+
*/
|
|
6
|
+
import z from "@deepseek-ai/schemastery";
|
|
7
|
+
/** Namespace of the share-links section — this plugin's own Loader entry id. */
|
|
8
|
+
export declare const NS_SHARE: import("@dsh-stack/plugin-kit").SettingsNamespace;
|
|
9
|
+
/** One self-hosted share-link configuration. */
|
|
10
|
+
export interface ShareConfig {
|
|
11
|
+
/** Whether the read-only share route is mounted at all. */
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
/** Whether interactive (token-gated) sharing is allowed for this deployment. */
|
|
14
|
+
allowInteractive: boolean;
|
|
15
|
+
/** Hosts the share link advertises (Tailscale name etc.); empty = loopback. */
|
|
16
|
+
advertisedHost?: string;
|
|
17
|
+
/** Base path the share route mounts under (default `/share`). */
|
|
18
|
+
basePath: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const ShareConfig: z<ShareConfig>;
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tweak-share-links settings: the share-links section schema shared between
|
|
3
|
+
* the composition entry, the settings document, and the `dsh share` verb.
|
|
4
|
+
* @module tweak-share-links/settings
|
|
5
|
+
*/
|
|
6
|
+
import z from "@deepseek-ai/schemastery";
|
|
7
|
+
import { settingsNamespace } from "@dsh-stack/plugin-kit";
|
|
8
|
+
/** Namespace of the share-links section — this plugin's own Loader entry id. */
|
|
9
|
+
export const NS_SHARE = settingsNamespace("tweak-share-links");
|
|
10
|
+
export const ShareConfig = z.object({
|
|
11
|
+
enabled: z.boolean().default(true),
|
|
12
|
+
allowInteractive: z.boolean().default(false),
|
|
13
|
+
advertisedHost: z.string().default(""),
|
|
14
|
+
basePath: z.string().default("/share"),
|
|
15
|
+
});
|
package/lib/share.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tweaks share links: a self-hosted read-only snapshot route. The plugin
|
|
3
|
+
* registers `<basePath>/<id>` as a prefix route on the harness web server;
|
|
4
|
+
* the handler reads the session's durable JSONL log and renders a static,
|
|
5
|
+
* dependency-free HTML transcript. Interactive mode (opt-in) additionally
|
|
6
|
+
* requires `?token=<random>` in the URL, checked against the value the
|
|
7
|
+
* `dsh share` verb wrote to the section. Read-only is the default: without a
|
|
8
|
+
* token the page is a pure snapshot with no interactive controls.
|
|
9
|
+
* @module tweak-share-links/share
|
|
10
|
+
*/
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
13
|
+
import type { ShareConfig } from "./settings.js";
|
|
14
|
+
/** One rendered transcript line from a session event. */
|
|
15
|
+
export interface TranscriptLine {
|
|
16
|
+
role: "user" | "assistant" | "tool" | "system";
|
|
17
|
+
text: string;
|
|
18
|
+
time?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Search every workspace segment under `sessions/` for the session's log. */
|
|
21
|
+
export declare function resolveSessionLogPath(home: string, sessionId: string, cwd?: string): Promise<string | undefined>;
|
|
22
|
+
/** Parse a durable JSONL log into transcript lines (stops at the first bad line). */
|
|
23
|
+
export declare function parseLog(text: string): TranscriptLine[];
|
|
24
|
+
/** Render a full share page from transcript lines. */
|
|
25
|
+
export declare function renderSharePage(sessionId: string, lines: TranscriptLine[], interactive: boolean): string;
|
|
26
|
+
/** Constant-time token comparison. */
|
|
27
|
+
export declare function tokensEqual(a: string, b: string): boolean;
|
|
28
|
+
/** Generate a fresh interactive token. */
|
|
29
|
+
export declare function generateToken(): string;
|
|
30
|
+
/** Store a share token beside the home settings (the `dsh share` verb writes this). */
|
|
31
|
+
export declare function writeShareToken(home: string, token: string): Promise<string>;
|
|
32
|
+
/** Read the stored share token, or `undefined` when sharing is read-only. */
|
|
33
|
+
export declare function readShareToken(home: string): Promise<string | undefined>;
|
|
34
|
+
/** List session id directories under a workspace segment. */
|
|
35
|
+
export declare function listSessionIds(home: string, cwd?: string): Promise<string[]>;
|
|
36
|
+
/**
|
|
37
|
+
* Build the route handler for one share base path. Read-only by default;
|
|
38
|
+
* interactive only when `allowInteractive` and the request carries the
|
|
39
|
+
* matching token.
|
|
40
|
+
*/
|
|
41
|
+
export declare function makeShareHandler(home: string, basePath: string, allowInteractive?: boolean): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Mount the share prefix route on the web server (called from `apply`).
|
|
44
|
+
* Returns the inject fiber.
|
|
45
|
+
*/
|
|
46
|
+
export declare function mountShareRoute(ctx: Context, home: string, config: ShareConfig): unknown;
|
package/lib/share.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tweaks share links: a self-hosted read-only snapshot route. The plugin
|
|
3
|
+
* registers `<basePath>/<id>` as a prefix route on the harness web server;
|
|
4
|
+
* the handler reads the session's durable JSONL log and renders a static,
|
|
5
|
+
* dependency-free HTML transcript. Interactive mode (opt-in) additionally
|
|
6
|
+
* requires `?token=<random>` in the URL, checked against the value the
|
|
7
|
+
* `dsh share` verb wrote to the section. Read-only is the default: without a
|
|
8
|
+
* token the page is a pure snapshot with no interactive controls.
|
|
9
|
+
* @module tweak-share-links/share
|
|
10
|
+
*/
|
|
11
|
+
import { join, dirname, basename } from "node:path";
|
|
12
|
+
import { readFile, readdir, mkdir, writeFile } from "node:fs/promises";
|
|
13
|
+
import { zstdDecompressSync } from "node:zlib";
|
|
14
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
15
|
+
/** Length of the interactive token, in bytes. */
|
|
16
|
+
const TOKEN_BYTES = 24;
|
|
17
|
+
/** The session-log folder name under the home. */
|
|
18
|
+
const SESSIONS_DIR = "sessions";
|
|
19
|
+
/** Map a workspace-cwd segment to the session folder prefix the harness uses. */
|
|
20
|
+
function workspaceSegment(cwd) {
|
|
21
|
+
const cleaned = cwd.replace(/^\/+|\/+$/g, "").replace(/\//g, "-");
|
|
22
|
+
return `--${cleaned}--`;
|
|
23
|
+
}
|
|
24
|
+
/** Candidate log paths under one workspace directory. */
|
|
25
|
+
function logCandidates(sessionDir) {
|
|
26
|
+
return [
|
|
27
|
+
join(sessionDir, "session.jsonl.zstd"),
|
|
28
|
+
join(sessionDir, "session.jsonl.ln"),
|
|
29
|
+
join(sessionDir, "session.jsonl"),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
/** Search every workspace segment under `sessions/` for the session's log. */
|
|
33
|
+
export async function resolveSessionLogPath(home, sessionId, cwd = "") {
|
|
34
|
+
const root = join(home, SESSIONS_DIR);
|
|
35
|
+
// Accept the id with or without the `session-` prefix.
|
|
36
|
+
const bare = sessionId.startsWith("session-") ? sessionId.slice("session-".length) : sessionId;
|
|
37
|
+
// Preferred first: the workspace segment derived from cwd.
|
|
38
|
+
const preferred = cwd.length > 0 ? cwd : process.cwd();
|
|
39
|
+
const dirs = [join(root, workspaceSegment(preferred))];
|
|
40
|
+
try {
|
|
41
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.isDirectory() || !entry.name.startsWith("--"))
|
|
44
|
+
continue;
|
|
45
|
+
const dir = join(root, entry.name);
|
|
46
|
+
if (!dirs.includes(dir))
|
|
47
|
+
dirs.push(dir);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// sessions/ missing entirely: nothing to find
|
|
52
|
+
}
|
|
53
|
+
for (const dir of dirs) {
|
|
54
|
+
const sessionDir = join(dir, `session-${bare}`);
|
|
55
|
+
for (const candidate of logCandidates(sessionDir)) {
|
|
56
|
+
try {
|
|
57
|
+
const stat = await import("node:fs/promises").then((fs) => fs.stat(candidate));
|
|
58
|
+
if (stat.isFile())
|
|
59
|
+
return candidate;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// try the next candidate
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/** Extract role/text from one parsed JSONL event line. */
|
|
69
|
+
function eventToLine(event) {
|
|
70
|
+
const type = event["type"];
|
|
71
|
+
const data = event["data"];
|
|
72
|
+
if (type === "user/message") {
|
|
73
|
+
const message = data?.["message"];
|
|
74
|
+
const content = message?.["content"];
|
|
75
|
+
return { role: "user", text: renderContent(content) };
|
|
76
|
+
}
|
|
77
|
+
if (type === "assistant/message") {
|
|
78
|
+
const message = data?.["message"];
|
|
79
|
+
const content = message?.["content"];
|
|
80
|
+
return { role: "assistant", text: renderContent(content) };
|
|
81
|
+
}
|
|
82
|
+
if (type === "tool/call") {
|
|
83
|
+
const name = data?.["name"];
|
|
84
|
+
return { role: "tool", text: `tool: ${String(name ?? "unknown")}` };
|
|
85
|
+
}
|
|
86
|
+
if (type === "tool/result") {
|
|
87
|
+
const ok = data?.["ok"];
|
|
88
|
+
return { role: "tool", text: `tool result: ${String(ok ?? "?")}` };
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
/** Render message content (string, array of parts, or absent) to plain text. */
|
|
93
|
+
function renderContent(content) {
|
|
94
|
+
if (typeof content === "string")
|
|
95
|
+
return content;
|
|
96
|
+
if (Array.isArray(content)) {
|
|
97
|
+
return content
|
|
98
|
+
.map((part) => {
|
|
99
|
+
const p = part;
|
|
100
|
+
if (typeof p["text"] === "string")
|
|
101
|
+
return p["text"];
|
|
102
|
+
if (p["type"] === "image")
|
|
103
|
+
return "[image]";
|
|
104
|
+
return "";
|
|
105
|
+
})
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.join("\n");
|
|
108
|
+
}
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
/** Parse a durable JSONL log into transcript lines (stops at the first bad line). */
|
|
112
|
+
export function parseLog(text) {
|
|
113
|
+
const lines = [];
|
|
114
|
+
for (const raw of text.split("\n")) {
|
|
115
|
+
const trimmed = raw.trim();
|
|
116
|
+
if (trimmed.length === 0)
|
|
117
|
+
continue;
|
|
118
|
+
let event;
|
|
119
|
+
try {
|
|
120
|
+
event = JSON.parse(trimmed);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
const line = eventToLine(event);
|
|
126
|
+
if (line !== undefined)
|
|
127
|
+
lines.push(line);
|
|
128
|
+
}
|
|
129
|
+
return lines;
|
|
130
|
+
}
|
|
131
|
+
/** Escape a string for safe HTML text emission. */
|
|
132
|
+
function escapeHtml(value) {
|
|
133
|
+
return value
|
|
134
|
+
.replace(/&/g, "&")
|
|
135
|
+
.replace(/</g, "<")
|
|
136
|
+
.replace(/>/g, ">")
|
|
137
|
+
.replace(/"/g, """);
|
|
138
|
+
}
|
|
139
|
+
/** Render a full share page from transcript lines. */
|
|
140
|
+
export function renderSharePage(sessionId, lines, interactive) {
|
|
141
|
+
const rows = lines
|
|
142
|
+
.map((line) => {
|
|
143
|
+
const cls = line.role;
|
|
144
|
+
const text = escapeHtml(line.text);
|
|
145
|
+
const time = line.time === undefined ? "" : `<time>${escapeHtml(line.time)}</time>`;
|
|
146
|
+
return `<div class="row ${cls}"><div class="role">${cls}</div><div class="text">${text}</div>${time}</div>`;
|
|
147
|
+
})
|
|
148
|
+
.join("\n");
|
|
149
|
+
return `<!doctype html>
|
|
150
|
+
<html lang="en">
|
|
151
|
+
<head>
|
|
152
|
+
<meta charset="utf-8" />
|
|
153
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
154
|
+
<title>session ${escapeHtml(sessionId)}</title>
|
|
155
|
+
<style>
|
|
156
|
+
body { font: 14px/1.6 system-ui, sans-serif; margin: 0; padding: 2rem; background: #101418; color: #d6dee8; }
|
|
157
|
+
h1 { font-size: 1rem; color: #8b97a6; }
|
|
158
|
+
.row { display: grid; grid-template-columns: 6rem 1fr auto; gap: .75rem; padding: .5rem 0; border-bottom: 1px solid #222a33; }
|
|
159
|
+
.role { color: #8b97a6; text-transform: uppercase; font-size: .75rem; padding-top: .2rem; }
|
|
160
|
+
.text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
161
|
+
time { color: #5b6673; font-size: .75rem; }
|
|
162
|
+
${interactive ? "body { padding-bottom: 5rem; } #live { position: fixed; bottom: 1rem; left: 2rem; right: 2rem; display: flex; gap: .5rem; } #live input { flex: 1; padding: .5rem; } #live button { padding: .5rem 1rem; }" : ""}
|
|
163
|
+
</style>
|
|
164
|
+
</head>
|
|
165
|
+
<body>
|
|
166
|
+
<h1>session ${escapeHtml(sessionId)}</h1>
|
|
167
|
+
${rows}
|
|
168
|
+
</body>
|
|
169
|
+
</html>
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
/** Constant-time token comparison. */
|
|
173
|
+
export function tokensEqual(a, b) {
|
|
174
|
+
const ha = createHash("sha256").update(a).digest();
|
|
175
|
+
const hb = createHash("sha256").update(b).digest();
|
|
176
|
+
return ha.equals(hb);
|
|
177
|
+
}
|
|
178
|
+
/** Generate a fresh interactive token. */
|
|
179
|
+
export function generateToken() {
|
|
180
|
+
return randomBytes(TOKEN_BYTES).toString("base64url");
|
|
181
|
+
}
|
|
182
|
+
/** Store a share token beside the home settings (the `dsh share` verb writes this). */
|
|
183
|
+
export async function writeShareToken(home, token) {
|
|
184
|
+
const path = join(home, "share.token");
|
|
185
|
+
await mkdir(dirname(path), { recursive: true });
|
|
186
|
+
await writeFile(path, `${token}\n`, { mode: 0o600 });
|
|
187
|
+
return path;
|
|
188
|
+
}
|
|
189
|
+
/** Read the stored share token, or `undefined` when sharing is read-only. */
|
|
190
|
+
export async function readShareToken(home) {
|
|
191
|
+
try {
|
|
192
|
+
const text = await readFile(join(home, "share.token"), "utf8");
|
|
193
|
+
return text.trim();
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** List session id directories under a workspace segment. */
|
|
200
|
+
export async function listSessionIds(home, cwd = "") {
|
|
201
|
+
const workspace = cwd.length > 0 ? cwd : process.cwd();
|
|
202
|
+
const dir = join(home, SESSIONS_DIR, workspaceSegment(workspace));
|
|
203
|
+
try {
|
|
204
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
205
|
+
return entries
|
|
206
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("session-"))
|
|
207
|
+
.map((entry) => basename(entry.name).slice("session-".length));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Read a session log, transparently decompressing a `.zstd` frame. */
|
|
214
|
+
async function readSessionLog(logPath) {
|
|
215
|
+
const raw = await readFile(logPath);
|
|
216
|
+
if (logPath.endsWith(".zstd")) {
|
|
217
|
+
return zstdDecompressSync(raw).toString("utf8");
|
|
218
|
+
}
|
|
219
|
+
return raw.toString("utf8");
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Build the route handler for one share base path. Read-only by default;
|
|
223
|
+
* interactive only when `allowInteractive` and the request carries the
|
|
224
|
+
* matching token.
|
|
225
|
+
*/
|
|
226
|
+
export function makeShareHandler(home, basePath, allowInteractive = false) {
|
|
227
|
+
return async (req, res) => {
|
|
228
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
229
|
+
const pathname = url.pathname;
|
|
230
|
+
const id = pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length + 1) : "";
|
|
231
|
+
if (id.length === 0 || id.includes("/")) {
|
|
232
|
+
res.writeHead(404);
|
|
233
|
+
res.end("not found");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const logPath = await resolveSessionLogPath(home, id);
|
|
237
|
+
if (logPath === undefined) {
|
|
238
|
+
res.writeHead(404);
|
|
239
|
+
res.end("not found");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const interactive = allowInteractive && (await authorizeShare(home, url.searchParams.get("token")));
|
|
243
|
+
const text = await readSessionLog(logPath);
|
|
244
|
+
const lines = parseLog(text);
|
|
245
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
246
|
+
res.end(renderSharePage(id, lines, interactive));
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** Whether an interactive request is authorized (token matches the stored one). */
|
|
250
|
+
async function authorizeShare(home, token) {
|
|
251
|
+
if (token === null || token.length === 0)
|
|
252
|
+
return false;
|
|
253
|
+
const stored = await readShareToken(home);
|
|
254
|
+
if (stored === undefined)
|
|
255
|
+
return false;
|
|
256
|
+
return tokensEqual(stored, token);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Mount the share prefix route on the web server (called from `apply`).
|
|
260
|
+
* Returns the inject fiber.
|
|
261
|
+
*/
|
|
262
|
+
export function mountShareRoute(ctx, home, config) {
|
|
263
|
+
if (!config.enabled)
|
|
264
|
+
return undefined;
|
|
265
|
+
const basePath = config.basePath === "" ? "/share" : config.basePath;
|
|
266
|
+
return ctx.inject(["webServer"], (httpCtx) => {
|
|
267
|
+
return httpCtx.webServer.register({
|
|
268
|
+
kind: "prefix",
|
|
269
|
+
path: basePath,
|
|
270
|
+
handler: makeShareHandler(home, basePath, config.allowInteractive),
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-stack/tweak-share-links",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Self-hosted read-only session share links: the share route, share settings section, and the dsh share verb.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"types": "lib/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./lib/index.d.ts",
|
|
12
|
+
"default": "./lib/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"bin",
|
|
18
|
+
"README.md",
|
|
19
|
+
"package.json",
|
|
20
|
+
"stack.json"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"stack": {
|
|
26
|
+
"kind": "plugin",
|
|
27
|
+
"id": "stack.tweaks.share-links"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.json",
|
|
31
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
32
|
+
"test": "node check-plugin.mjs"
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"share": "bin/share.mjs"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
39
|
+
"@deepseek-ai/schemastery": "*",
|
|
40
|
+
"@deepseek-ai/dsh-settings": "0.2.0-rc.2",
|
|
41
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@dsh-stack/tweaks": "0.2.1",
|
|
45
|
+
"@dsh-stack/plugin-kit": "0.1.1"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"typescript": "^6.0.3",
|
|
49
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
50
|
+
"@deepseek-ai/schemastery": "*",
|
|
51
|
+
"@deepseek-ai/dsh-settings": "0.2.0-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "https://github.com/marius-patrik/dsh-stack.git",
|
|
57
|
+
"directory": "plugins/tweak-share-links"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/stack.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "stack.tweaks.share-links",
|
|
3
|
+
"name": "@dsh-stack/tweak-share-links",
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"kind": "plugin",
|
|
6
|
+
"description": "Self-hosted read-only session share links: the share route, share settings section, and the dsh share verb.",
|
|
7
|
+
"files": ["lib", "bin", "README.md", "package.json", "stack.json"],
|
|
8
|
+
"dependencies": ["stack.tweaks", "stack.library.plugin-kit"],
|
|
9
|
+
"optionalDependencies": []
|
|
10
|
+
}
|