dsh-claude-whiteboard 2.14.0 → 2.14.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/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dsh-claude-whiteboard 2.14.
|
|
1
|
+
// dsh-claude-whiteboard 2.14.1: built from baoyu-design.
|
|
2
2
|
var define_PLUGIN_default = { name: "claude-whiteboard", provider: "dsh-claude-whiteboard", skills: [{ name: "claude-whiteboard", description: "Use this for a local Whiteboard: a shared space for laying out thinking in whatever form it needs (a diagram, a flow, a map of ideas, a plan, a comparison, a drawn explanation, a small working embed) that opens in the bundled board editor, where people move, draw, comment and send comments to the agent. Its content lives in the board's own store, written with this skill's script, never in files you edit by hand. Read it before you create a board or change one. Not for a slide deck, a document, a film or a static HTML page.", scriptAlias: "A", comments: true }] };
|
|
3
3
|
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
@@ -110,13 +110,14 @@ function runtimeSection(skill, skillDir, node, platform = process.platform, watc
|
|
|
110
110
|
skill.commentsBy === "mention" ? "(read the block, change the doc with `update`, then `comment-reply`)." : "(change the files, then `comment-reply`).",
|
|
111
111
|
"So do not start `comments --wait` yourself, where Comments says to: run `open` in the foreground (not",
|
|
112
112
|
"`run_in_background`), and the wait starts with it. It stops when the editor stops; after `stop`, the next `open`",
|
|
113
|
-
"starts it again.",
|
|
113
|
+
"starts it again. Harness starts the next wait when your turn ends, which is where this skill says to wait again:",
|
|
114
|
+
"finish what a message asks for in the turn that takes it.",
|
|
114
115
|
...skill.commentsBy === "mention" ? ["", "When you first give the link, tell the user that a comment with `@agent` in it reaches you (type `@` and pick agent)."] : [],
|
|
115
116
|
...skill.requests ? [
|
|
116
117
|
"",
|
|
117
118
|
"The page's buttons reach you the same way: a request one of them sends arrives as such a message, under",
|
|
118
|
-
"`requests` (
|
|
119
|
-
"
|
|
119
|
+
"`requests` (the button in `label`, what it asks in `text`), as `comments <root> --wait` printed it for that",
|
|
120
|
+
"page. It is a request from the page wherever this skill speaks of one: handle it as this skill says for those."
|
|
120
121
|
] : []
|
|
121
122
|
);
|
|
122
123
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-claude-whiteboard",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.1",
|
|
4
4
|
"description": "DeepSeek Harness plugin: the claude-whiteboard skill from baoyu-design. The agent lays out diagrams, flows, maps of ideas and plans on a whiteboard and opens it in a local board editor where you move, draw and comment.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
"icon": "./icon.svg",
|
|
25
25
|
"baoyuDesign": {
|
|
26
26
|
"releaseNotes": [
|
|
27
|
+
{
|
|
28
|
+
"version": "2.14.1",
|
|
29
|
+
"date": "2026-11-11",
|
|
30
|
+
"en": "### Features\n- **DeepSeek Harness: open your work from another device.** When Harness runs on one computer and you use it from another (`dsh web` on a server, opened in a browser on your laptop or phone over the same network or a tailnet), opening an item from **Artifacts**, or **View thread** on a comment card, gave a `http://127.0.0.1:…` link that only that computer can open. The plugin now gives the device you are on a link of its own, at the address your browser reaches Harness by, and opens it in a new tab. Through it you are the editor's owner, as on that computer: you edit, and **Send to Agent** is there. Behind a proxy or a domain of your own, set `BAOYU_DESIGN_REMOTE_HOST` to the address other devices reach that computer at; `off` turns this off. Every editor's `open` takes `--remote[=<host>]` to print that owner's link (`--remote=off` closes it), and `share <folder> --reset owner` replaces its key. See [Use it from another device](https://github.com/JimLiu/baoyu-design/blob/main/dsh-plugins/README.md#use-it-from-another-device) (requested by @akaDeyve, [#40](https://github.com/JimLiu/baoyu-design/issues/40)).\n- **DeepSeek Harness: `dsh-claude-dashboard` updates to `dsh-claude-data`.** The Artifacts page names the type **Data**, and offers an installed `dsh-claude-dashboard` as an update, `claude-dashboard → claude-data`: **Update** installs `dsh-claude-data`, switched on or off as the old one was, then uninstalls `dsh-claude-dashboard`. A preset that names `dsh-claude-dashboard` keeps it, and the page says what to change. 2.14.0 said `dsh-claude-dashboard` would get no more updates; instead it keeps getting releases with no skill in them. An Artifacts page from 2.13.0 or earlier offers that release as an update, and it brings the page that offers the move, so click **Update** once more when the move shows.\n\n### Fixes\n- `claude-design-system`: a guest on a **Share** link could not add an asset or a font. The page stayed at **Adding …**, because a page served over plain http on the network lacks the browser's hashing that the upload used. It now hashes the file itself.\n- DeepSeek Harness: uninstalling the plugin that was running the Artifacts page left the page at a 404 until the app restarted. Another installed plugin now takes it over at once.",
|
|
31
|
+
"zh": "### 新功能\n- **DeepSeek Harness:在另一台设备上打开你的作品。** Harness 跑在一台电脑上、你在另一台设备上用时(比如服务器上跑 `dsh web`,在笔记本或手机的浏览器里通过同一个局域网或 tailnet 打开),从 **Artifacts**(「作品」)打开,或者点评论卡片上的 **View thread**,拿到的是 `http://127.0.0.1:…`,只有那台电脑打得开。现在插件会给你手上这台设备一个它自己的链接,地址就是浏览器访问 Harness 用的那个,在新标签页里打开。通过它打开,你就是编辑器的主人,和在那台电脑上一样:能编辑,也有 **Send to Agent**。经过代理或用自己的域名访问时,把 `BAOYU_DESIGN_REMOTE_HOST` 设成其他设备访问那台电脑用的地址;设成 `off` 就关掉这个功能。每个编辑器的 `open` 都可以加 `--remote[=<host>]`,打出这个主人链接(`--remote=off` 关掉),`share <文件夹> --reset owner` 换一把钥匙。见[在另一台设备上用](https://github.com/JimLiu/baoyu-design/blob/main/dsh-plugins/README.zh-CN.md#在另一台设备上用)(@akaDeyve 提出,[#40](https://github.com/JimLiu/baoyu-design/issues/40))。\n- **DeepSeek Harness:`dsh-claude-dashboard` 可以更新成 `dsh-claude-data`。** 「作品」页上这个类型叫 **数据**(Data),装着 `dsh-claude-dashboard` 的,会把换到新插件当作一项更新来提示,写作 `claude-dashboard → claude-data`:点 **Update**(更新)会先装上 `dsh-claude-data`(开关和旧插件一样),再卸载 `dsh-claude-dashboard`。预设里写了 `dsh-claude-dashboard` 的,旧插件会留着,页面上会说要改哪里。2.14.0 说 `dsh-claude-dashboard` 不再更新;现在改成它继续发不带技能的版本。2.13.0 或更早的「作品」页会把这个版本当作更新来提示,装上它就有了会提示换插件的新「作品」页,提示出来后再点一次 **Update**。\n\n### 修复\n- `claude-design-system`:通过 **Share**(分享)链接进来的访客加不了资源和字体,页面一直停在 **Adding …**。局域网上用明文 http 打开的页面没有浏览器自带的哈希功能,上传要用它。现在由页面自己算。\n- DeepSeek Harness:卸载正在提供「作品」页的那个插件后,这个页面一直是 404,要重启应用才好。现在另一个装着的插件会马上接手。"
|
|
32
|
+
},
|
|
27
33
|
{
|
|
28
34
|
"version": "2.14.0",
|
|
29
35
|
"date": "2026-11-11",
|
|
@@ -65,12 +71,6 @@
|
|
|
65
71
|
"date": "2026-11-05",
|
|
66
72
|
"en": "### Features\n- **DeepSeek Harness: the Artifacts page offers the plugins you haven't installed.** Beside the tiles for the skills you have, each other baoyu-design plugin gets a dimmed tile marked **Not installed**. Click it to see what the plugin makes and install it from there, at the same version as the ones you have, usually without a restart. Once it's in, **Start** opens a session with the skill. In an app that can't install from the page, the tile points you to the Plugins page. A plugin you installed and switched off still gets no tile.\n\n### Fixes\n- With `--agent-hook`, a comment could reach the agent twice: a second send could start the hook again after the first run had finished but before the comment was marked sent.",
|
|
67
73
|
"zh": "### 新功能\n- **DeepSeek Harness:「作品」页会列出你还没装的插件。** 已装技能的入口旁边,其他 baoyu-design 插件也各有一个入口,颜色淡一些,标着 **未安装**。点开会说明这个插件做什么,可以直接在这里安装,版本和你已装的一致,多数情况不用重启。装好后点 **新建** 就用这个技能开一个会话。应用不支持在页面里安装时,会引导你去插件页。装了但被你关掉的插件,仍然没有入口。\n\n### 修复\n- 用 `--agent-hook` 时,一条评论可能发给 agent 两次:第一次运行已经结束、评论还没标成已发送时,第二次发送会再启动一次 hook。"
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
"version": "2.11.0",
|
|
71
|
-
"date": "2026-11-05",
|
|
72
|
-
"en": "### Features\n- **DeepSeek Harness: a new version no longer pops up over the app.** The **Artifacts** row in the left sidebar gets an **Update** label instead (a dot on the icon, where a collapsed sidebar keeps its icons), and a panel at the top of the Artifacts page shows the versions, each plugin and what the release changes. **Update all** updates them together; **Update** on a plugin's line takes just that one. **Later** folds the panel to one line and removes the label until the next version.\n- While an update runs you can leave the Artifacts page: the label says **Updating**, **Failed** or **Restart**. **Try again** picks up where it stopped: the rest after **Update all**, that plugin after its own **Update**.\n- The **Update** button on a plugin's page now updates only that plugin, and a page that was open when its plugin was replaced comes back by itself.",
|
|
73
|
-
"zh": "### 新功能\n- **DeepSeek Harness:新版本不再弹出来打断你。** 左侧栏的 **Artifacts**(「作品」)这一行会多一个 **更新** 标签(侧栏收起后还留着图标的话,是图标上的小圆点),「作品」页顶部的面板写着版本、每个插件和这次改了什么。**全部更新** 一起更新;每个插件那一行的 **更新** 只更新它自己。**稍后** 把面板收成一行,标签也消失,等下一个版本再提示。\n- 更新时可以离开「作品」页:标签会显示 **更新中**、**更新失败** 或 **待重启**。**重试** 从停下的地方接着来:**全部更新** 失败就接着更新剩下的,单独更新失败就再试这一个。\n- 插件详情页的 **更新** 按钮现在只更新这一个插件;更新时开着的页面被替换后,会自己回来。"
|
|
74
74
|
}
|
|
75
75
|
]
|
|
76
76
|
},
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"dsh": ">=0.2.0-0"
|
|
97
97
|
},
|
|
98
98
|
"dependencies": {
|
|
99
|
-
"dsh-claude-artifacts": "^2.14.
|
|
99
|
+
"dsh-claude-artifacts": "^2.14.1"
|
|
100
100
|
},
|
|
101
101
|
"dsh": {
|
|
102
102
|
"bundle": {
|
|
@@ -242,6 +242,7 @@ function sendFile(request, response, file, headers) {
|
|
|
242
242
|
var errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
243
243
|
|
|
244
244
|
import { spawn } from "node:child_process";
|
|
245
|
+
import dns from "node:dns/promises";
|
|
245
246
|
import { EventEmitter } from "node:events";
|
|
246
247
|
import fs3 from "node:fs";
|
|
247
248
|
import http from "node:http";
|
|
@@ -297,6 +298,7 @@ var Sharing = class _Sharing {
|
|
|
297
298
|
if (isRecord(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
|
|
298
299
|
}
|
|
299
300
|
}
|
|
301
|
+
if (isRecord(raw.owner) && typeof raw.owner.token === "string" && raw.owner.token.length >= 32) state.owner = { token: raw.owner.token };
|
|
300
302
|
if (Array.isArray(raw.guests)) {
|
|
301
303
|
for (const g of raw.guests) {
|
|
302
304
|
if (!isRecord(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
|
|
@@ -327,6 +329,27 @@ var Sharing = class _Sharing {
|
|
|
327
329
|
}
|
|
328
330
|
return null;
|
|
329
331
|
}
|
|
332
|
+
/** The owner's link token (`state.owner`), made and saved the first time it is asked for. */
|
|
333
|
+
ownerToken() {
|
|
334
|
+
if (!this.state.owner) {
|
|
335
|
+
this.state.owner = { token: token(32) };
|
|
336
|
+
this.save();
|
|
337
|
+
}
|
|
338
|
+
return this.state.owner.token;
|
|
339
|
+
}
|
|
340
|
+
/** Whether `given` is the owner's link token (a cookie or a link's). */
|
|
341
|
+
isOwnerToken(given) {
|
|
342
|
+
const own = this.state.owner?.token;
|
|
343
|
+
if (!given || !own) return false;
|
|
344
|
+
const a = Buffer.from(given);
|
|
345
|
+
const b = Buffer.from(own);
|
|
346
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
347
|
+
}
|
|
348
|
+
/** A new owner's link: the old one, and every browser that came in through it, stop working. */
|
|
349
|
+
resetOwner() {
|
|
350
|
+
this.state.owner = { token: token(32) };
|
|
351
|
+
this.save();
|
|
352
|
+
}
|
|
330
353
|
/** The guest a cookie names, if they still have access (their link on and not reset). */
|
|
331
354
|
guest(secret) {
|
|
332
355
|
if (!secret) return null;
|
|
@@ -588,6 +611,8 @@ var Hub = class {
|
|
|
588
611
|
hosts = /* @__PURE__ */ new Set();
|
|
589
612
|
/** One listener per LAN address while any site is open to the network. */
|
|
590
613
|
lanServers = /* @__PURE__ */ new Map();
|
|
614
|
+
/** Names that resolve to an address listened on, asked for by `allowName` (lower case, no port). */
|
|
615
|
+
lanNames = /* @__PURE__ */ new Set();
|
|
591
616
|
network;
|
|
592
617
|
constructor(options) {
|
|
593
618
|
this.echo = options.echo;
|
|
@@ -603,7 +628,8 @@ var Hub = class {
|
|
|
603
628
|
},
|
|
604
629
|
addresses: () => [...this.lanServers.keys()],
|
|
605
630
|
open: () => this.openLan(),
|
|
606
|
-
release: () => this.releaseLan()
|
|
631
|
+
release: () => this.releaseLan(),
|
|
632
|
+
allowName: (name) => this.allowName(name)
|
|
607
633
|
};
|
|
608
634
|
}
|
|
609
635
|
createServer() {
|
|
@@ -722,9 +748,9 @@ var Hub = class {
|
|
|
722
748
|
}
|
|
723
749
|
return [...this.lanServers.keys()];
|
|
724
750
|
}
|
|
725
|
-
/** A site stopped sharing: the listeners stay while another is shared. */
|
|
751
|
+
/** A site stopped sharing: the listeners stay while another is shared, or open to its owner on another device. */
|
|
726
752
|
releaseLan() {
|
|
727
|
-
if (![...this.sites.values()].some((site) => site.share.
|
|
753
|
+
if (![...this.sites.values()].some((site) => site.share.listening)) this.closeLan();
|
|
728
754
|
}
|
|
729
755
|
closeLan() {
|
|
730
756
|
for (const server of this.lanServers.values()) {
|
|
@@ -732,8 +758,30 @@ var Hub = class {
|
|
|
732
758
|
server.closeAllConnections?.();
|
|
733
759
|
}
|
|
734
760
|
this.lanServers.clear();
|
|
761
|
+
this.lanNames.clear();
|
|
762
|
+
}
|
|
763
|
+
/** The machine's own names, as hostAllowed takes them. */
|
|
764
|
+
isMachineName(name) {
|
|
765
|
+
const machine = os2.hostname().toLowerCase();
|
|
766
|
+
return name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
|
|
735
767
|
}
|
|
736
|
-
/**
|
|
768
|
+
/**
|
|
769
|
+
* Answer at `name` too while listening: an address listened on, the machine's name, or a name that resolves to an
|
|
770
|
+
* address listened on (looked up once, now: Tailscale's MagicDNS name, a hosts entry).
|
|
771
|
+
*/
|
|
772
|
+
async allowName(name) {
|
|
773
|
+
const lower = name.toLowerCase();
|
|
774
|
+
if (!lower || !this.lanServers.size) return false;
|
|
775
|
+
if (this.lanServers.has(lower) || this.isMachineName(lower) || this.lanNames.has(lower)) return true;
|
|
776
|
+
const found = await dns.lookup(lower, { all: true, family: 4 }).catch(() => []);
|
|
777
|
+
if (!found.some((entry) => this.lanServers.has(entry.address))) return false;
|
|
778
|
+
this.lanNames.add(lower);
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* A Host header this hub answers: its own names, and while something is shared (or open to its owner on another
|
|
783
|
+
* device), its LAN addresses, its machine name and the names allowName took.
|
|
784
|
+
*/
|
|
737
785
|
hostAllowed(request) {
|
|
738
786
|
const host = request.headers.host ?? "";
|
|
739
787
|
if (this.hosts.has(host)) return true;
|
|
@@ -741,8 +789,7 @@ var Hub = class {
|
|
|
741
789
|
const suffix = `:${this.port}`;
|
|
742
790
|
if (!host.endsWith(suffix)) return false;
|
|
743
791
|
const name = host.slice(0, -suffix.length).toLowerCase();
|
|
744
|
-
|
|
745
|
-
return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
|
|
792
|
+
return this.lanServers.has(name) || this.isMachineName(name) || this.lanNames.has(name);
|
|
746
793
|
}
|
|
747
794
|
// ---- requests --------------------------------------------------------------------------------------------------
|
|
748
795
|
/** The site whose address `pathname` is under, with the path below it. */
|
|
@@ -1330,6 +1377,12 @@ function commentsViewer(viewer) {
|
|
|
1330
1377
|
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1331
1378
|
var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1332
1379
|
var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
1380
|
+
function hostName(host) {
|
|
1381
|
+
const value = host.trim().toLowerCase();
|
|
1382
|
+
if (value.startsWith("[")) return value.slice(1, value.indexOf("]") > 0 ? value.indexOf("]") : void 0);
|
|
1383
|
+
const colons = value.split(":").length - 1;
|
|
1384
|
+
return colons === 1 ? value.slice(0, value.indexOf(":")) : value;
|
|
1385
|
+
}
|
|
1333
1386
|
function localOwner() {
|
|
1334
1387
|
let name = "You";
|
|
1335
1388
|
try {
|
|
@@ -1351,6 +1404,8 @@ var ShareHost = class {
|
|
|
1351
1404
|
page;
|
|
1352
1405
|
/** Open to the local network now (the hub's listeners are up, for this artifact too). */
|
|
1353
1406
|
on = false;
|
|
1407
|
+
/** Open to its owner on another device (`open --remote`): the listeners are up, guests stay out unless `on`. */
|
|
1408
|
+
remote = false;
|
|
1354
1409
|
live = /* @__PURE__ */ new Set();
|
|
1355
1410
|
constructor(options) {
|
|
1356
1411
|
this.options = options;
|
|
@@ -1376,6 +1431,10 @@ var ShareHost = class {
|
|
|
1376
1431
|
get shared() {
|
|
1377
1432
|
return this.on;
|
|
1378
1433
|
}
|
|
1434
|
+
/** Someone may reach it over the network now (guests, or its owner on another device): the hub keeps listening. */
|
|
1435
|
+
get listening() {
|
|
1436
|
+
return this.on || this.remote;
|
|
1437
|
+
}
|
|
1379
1438
|
/** The artifact's name, for the join page and the list of what is open at the server's root. */
|
|
1380
1439
|
title() {
|
|
1381
1440
|
return this.options.title();
|
|
@@ -1398,30 +1457,61 @@ var ShareHost = class {
|
|
|
1398
1457
|
this.options.log(`shared on the local network: ${addresses.map((a) => `http://${a}:${this.port}${this.page}`).join(", ") || "(no LAN address)"}`);
|
|
1399
1458
|
return addresses;
|
|
1400
1459
|
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Opens the artifact to its owner on another device, or closes that, and returns the owner's link, at `host` when
|
|
1462
|
+
* that is this computer's (the address the owner's browser already reaches it by), else at its first network address.
|
|
1463
|
+
* Guests are not let in by it: that is `setLan`.
|
|
1464
|
+
*/
|
|
1465
|
+
async setRemote(on, host) {
|
|
1466
|
+
if (!on) {
|
|
1467
|
+
this.remote = false;
|
|
1468
|
+
for (const live of [...this.live]) if (live.viewer.remote) live.close("unshared");
|
|
1469
|
+
this.network.release();
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
const addresses = await this.network.open();
|
|
1473
|
+
if (!addresses.length) {
|
|
1474
|
+
this.network.release();
|
|
1475
|
+
throw new ShareError("This computer has no network address to open it at.");
|
|
1476
|
+
}
|
|
1477
|
+
this.remote = true;
|
|
1478
|
+
const name = hostName(host ?? "");
|
|
1479
|
+
const sorted = [...addresses].sort((a, b) => Number(isTailnet(a)) - Number(isTailnet(b)));
|
|
1480
|
+
const address = name && (addresses.includes(name) || await this.network.allowName?.(name)) ? name : sorted[0];
|
|
1481
|
+
const url = `http://${address.includes(":") ? `[${address}]` : address}:${this.port}${this.page}o/${this.sharing.ownerToken()}`;
|
|
1482
|
+
this.options.log(`open to its owner on another device at http://${address}:${this.port}${this.page}`);
|
|
1483
|
+
return { url, address };
|
|
1484
|
+
}
|
|
1401
1485
|
/** The artifact left the hub: it stays shared in .sharing.json, for the next time it opens. */
|
|
1402
1486
|
close() {
|
|
1403
1487
|
this.on = false;
|
|
1488
|
+
this.remote = false;
|
|
1404
1489
|
this.network.release();
|
|
1405
1490
|
}
|
|
1406
1491
|
// ---- who is asking ---------------------------------------------------------------------------------------------
|
|
1407
|
-
cookie(request) {
|
|
1492
|
+
cookie(request, name = this.options.cookieName) {
|
|
1408
1493
|
for (const part of (request.headers.cookie ?? "").split(";")) {
|
|
1409
1494
|
const eq = part.indexOf("=");
|
|
1410
|
-
if (eq > 0 && part.slice(0, eq).trim() ===
|
|
1495
|
+
if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
1411
1496
|
}
|
|
1412
1497
|
return void 0;
|
|
1413
1498
|
}
|
|
1499
|
+
/** The cookie the owner's link leaves in a browser on another device. */
|
|
1500
|
+
get ownerCookie() {
|
|
1501
|
+
return `${this.options.cookieName}_owner`;
|
|
1502
|
+
}
|
|
1414
1503
|
/**
|
|
1415
|
-
* Who a request is: the owner over loopback,
|
|
1416
|
-
*
|
|
1417
|
-
*
|
|
1418
|
-
* what a role sees).
|
|
1504
|
+
* Who a request is: the owner over loopback, or by the owner's link's cookie while the artifact is open to them on
|
|
1505
|
+
* another device; a guest by their cookie (while their link is on and the artifact is shared), else nobody. Loopback
|
|
1506
|
+
* is always the owner, so trying a link on this computer can't lock the owner out of their own artifact; a guest
|
|
1507
|
+
* comes over a LAN address (this computer's own included: that is how the owner sees what a role sees).
|
|
1419
1508
|
*/
|
|
1420
1509
|
isLocal(request) {
|
|
1421
1510
|
return this.options.local ? this.options.local(request) : isLoopback(request.socket.remoteAddress);
|
|
1422
1511
|
}
|
|
1423
1512
|
viewerOf(request) {
|
|
1424
1513
|
if (this.isLocal(request)) return { role: "owner", user: this.options.owner };
|
|
1514
|
+
if (this.remote && this.sharing.isOwnerToken(this.cookie(request, this.ownerCookie))) return { role: "owner", user: this.options.owner, remote: true };
|
|
1425
1515
|
const guest = this.on ? this.sharing.guest(this.cookie(request)) : null;
|
|
1426
1516
|
if (!guest) return null;
|
|
1427
1517
|
this.sharing.seen(guest);
|
|
@@ -1455,7 +1545,8 @@ var ShareHost = class {
|
|
|
1455
1545
|
const p = new URL(request.url ?? "/", "http://local").pathname;
|
|
1456
1546
|
const method = request.method ?? "GET";
|
|
1457
1547
|
const link = /^\/s\/([A-Za-z0-9_-]{16,64})$/.exec(p);
|
|
1458
|
-
const
|
|
1548
|
+
const ownerLink = /^\/o\/([A-Za-z0-9_-]{16,64})$/.exec(p);
|
|
1549
|
+
const ours = link || ownerLink || p === "/__share" || p.startsWith("/__share/");
|
|
1459
1550
|
if (!ours) return false;
|
|
1460
1551
|
if (method === "POST" && !sameOrigin(request)) {
|
|
1461
1552
|
request.resume();
|
|
@@ -1467,6 +1558,12 @@ var ShareHost = class {
|
|
|
1467
1558
|
else response.writeHead(405, { Allow: "GET, POST" }).end();
|
|
1468
1559
|
return true;
|
|
1469
1560
|
}
|
|
1561
|
+
if (ownerLink) {
|
|
1562
|
+
request.resume();
|
|
1563
|
+
if (method === "GET" || method === "HEAD") this.ownerIn(request, response, ownerLink[1]);
|
|
1564
|
+
else response.writeHead(405, { Allow: "GET" }).end();
|
|
1565
|
+
return true;
|
|
1566
|
+
}
|
|
1470
1567
|
const viewer = this.viewerOf(request);
|
|
1471
1568
|
if (!viewer) {
|
|
1472
1569
|
this.denied(request, response);
|
|
@@ -1550,6 +1647,24 @@ var ShareHost = class {
|
|
|
1550
1647
|
const url = `http://127.0.0.1:${this.port}${this.page}`;
|
|
1551
1648
|
return `<p class="note">${esc(t.ownerHere)} <a href="${esc(url)}">${esc(url)}</a></p>`;
|
|
1552
1649
|
}
|
|
1650
|
+
/**
|
|
1651
|
+
* GET /o/<token>: the owner's link. On another device it leaves the owner's cookie (while the artifact is open to them
|
|
1652
|
+
* there) and goes on to the page, its query kept (`?comment=` opens a thread); over loopback it is the page anyway.
|
|
1653
|
+
*/
|
|
1654
|
+
ownerIn(request, response, token2) {
|
|
1655
|
+
const local = this.isLocal(request);
|
|
1656
|
+
if (!this.sharing.isOwnerToken(token2) || !local && !this.remote) {
|
|
1657
|
+
const t = this.words(request);
|
|
1658
|
+
return this.sendPage(response, 404, t.linkGoneTitle, `<h1>${esc(t.linkGoneTitle)}</h1><p>${esc(t.linkGone)}</p>`);
|
|
1659
|
+
}
|
|
1660
|
+
const search = new URL(request.url ?? "/", "http://local").search;
|
|
1661
|
+
const headers = { Location: `${this.page}${search}`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
|
|
1662
|
+
if (!local) {
|
|
1663
|
+
headers["Set-Cookie"] = `${this.ownerCookie}=${token2}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`;
|
|
1664
|
+
this.options.log(`share: the owner came in from another device (${request.socket.remoteAddress})`);
|
|
1665
|
+
}
|
|
1666
|
+
response.writeHead(303, headers).end();
|
|
1667
|
+
}
|
|
1553
1668
|
/** GET /s/<token>: the name form (or straight in, for a guest already in through this link); POST: join. */
|
|
1554
1669
|
async join(request, response, method, token2) {
|
|
1555
1670
|
const t = this.words(request);
|
|
@@ -1607,6 +1722,7 @@ var ShareHost = class {
|
|
|
1607
1722
|
return {
|
|
1608
1723
|
title: this.options.title(),
|
|
1609
1724
|
lan: this.on,
|
|
1725
|
+
remote: this.remote,
|
|
1610
1726
|
port: this.port,
|
|
1611
1727
|
page: this.page,
|
|
1612
1728
|
addresses,
|
|
@@ -1618,11 +1734,13 @@ var ShareHost = class {
|
|
|
1618
1734
|
}
|
|
1619
1735
|
/**
|
|
1620
1736
|
* The owner's sharing calls: `lan` {on}, `link` {role, on}, `reset` {role} (a new link: the old one and the guests
|
|
1621
|
-
* who came through it stop working), `role` {guest, role} (a guest's role, while
|
|
1622
|
-
* {guest}
|
|
1737
|
+
* who came through it stop working; role `owner` the owner's own link), `role` {guest, role} (a guest's role, while
|
|
1738
|
+
* that role's link is on), `remove` {guest}, `remote` {on, host?} (open to the owner on another device: the answer
|
|
1739
|
+
* then has `remoteLink` {url, address}, the owner's link). Answers the view after.
|
|
1623
1740
|
*/
|
|
1624
1741
|
async share(action) {
|
|
1625
1742
|
const role = action.role;
|
|
1743
|
+
let remote = null;
|
|
1626
1744
|
switch (action.action) {
|
|
1627
1745
|
case "lan": {
|
|
1628
1746
|
const addresses = await this.setLan(action.on === true);
|
|
@@ -1634,9 +1752,17 @@ var ShareHost = class {
|
|
|
1634
1752
|
this.sharing.setLink(role, action.on === true);
|
|
1635
1753
|
break;
|
|
1636
1754
|
case "reset":
|
|
1637
|
-
if (
|
|
1755
|
+
if (role === "owner") {
|
|
1756
|
+
this.sharing.resetOwner();
|
|
1757
|
+
for (const live of [...this.live]) if (live.viewer.remote) live.close("revoked");
|
|
1758
|
+
break;
|
|
1759
|
+
}
|
|
1760
|
+
if (!isGuestRole(role)) throw new ShareError("role: owner, editor, commenter or viewer");
|
|
1638
1761
|
this.sharing.resetLink(role);
|
|
1639
1762
|
break;
|
|
1763
|
+
case "remote":
|
|
1764
|
+
remote = await this.setRemote(action.on === true, typeof action.host === "string" ? action.host : void 0);
|
|
1765
|
+
break;
|
|
1640
1766
|
case "role": {
|
|
1641
1767
|
if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
|
|
1642
1768
|
if (typeof action.guest !== "string" || !this.sharing.state.guests.some((g) => g.id === action.guest)) throw new ShareError("no such guest");
|
|
@@ -1648,11 +1774,11 @@ var ShareHost = class {
|
|
|
1648
1774
|
if (typeof action.guest !== "string" || !this.sharing.removeGuest(action.guest)) throw new ShareError("no such guest");
|
|
1649
1775
|
break;
|
|
1650
1776
|
default:
|
|
1651
|
-
throw new ShareError("action: lan, link, reset, role or
|
|
1777
|
+
throw new ShareError("action: lan, link, reset, role, remove or remote");
|
|
1652
1778
|
}
|
|
1653
1779
|
this.options.log(`share: ${JSON.stringify(action)}`);
|
|
1654
1780
|
this.dropRevoked();
|
|
1655
|
-
return this.shareView();
|
|
1781
|
+
return { ...this.shareView(), ...remote ? { remoteLink: remote } : {} };
|
|
1656
1782
|
}
|
|
1657
1783
|
async shareApi(request, response, method) {
|
|
1658
1784
|
if (method === "GET") return sendJson(response, 200, this.shareView());
|
|
@@ -2906,11 +3032,11 @@ async function shareCall(url, action, fail2) {
|
|
|
2906
3032
|
if (!response.ok) fail2("SHARE_REFUSED", String(body.message ?? `the server answered ${response.status}`));
|
|
2907
3033
|
return body;
|
|
2908
3034
|
}
|
|
2909
|
-
function rolesOf(flags, key, fail2) {
|
|
3035
|
+
function rolesOf(flags, key, fail2, owner = false) {
|
|
2910
3036
|
const given = flags.get(key);
|
|
2911
3037
|
if (given === void 0) return [];
|
|
2912
3038
|
const roles = given === "all" || given === "true" ? [...GUEST_ROLES] : given.split(",").map((r) => r.trim());
|
|
2913
|
-
for (const role of roles) if (!isGuestRole(role)) fail2("INVALID_ROLE", `--${key}: editor, commenter, viewer or all (got ${role}).`);
|
|
3039
|
+
for (const role of roles) if (!isGuestRole(role) && !(owner && role === "owner")) fail2("INVALID_ROLE", `--${key}: ${owner ? "owner, " : ""}editor, commenter, viewer or all (got ${role}).`);
|
|
2914
3040
|
return roles;
|
|
2915
3041
|
}
|
|
2916
3042
|
function guestRoles(flags, fail2) {
|
|
@@ -2925,7 +3051,7 @@ function guestRoles(flags, fail2) {
|
|
|
2925
3051
|
async function shareCommand(o) {
|
|
2926
3052
|
const off = o.flags.has("off");
|
|
2927
3053
|
const changes = [
|
|
2928
|
-
...rolesOf(o.flags, "reset", o.fail).map((role) => ({ action: "reset", role })),
|
|
3054
|
+
...rolesOf(o.flags, "reset", o.fail, true).map((role) => ({ action: "reset", role })),
|
|
2929
3055
|
...rolesOf(o.flags, "enable", o.fail).map((role) => ({ action: "link", role, on: true })),
|
|
2930
3056
|
...rolesOf(o.flags, "disable", o.fail).map((role) => ({ action: "link", role, on: false })),
|
|
2931
3057
|
...guestRoles(o.flags, o.fail)
|
|
@@ -2935,7 +3061,8 @@ async function shareCommand(o) {
|
|
|
2935
3061
|
if (!server) {
|
|
2936
3062
|
const sharing = new Sharing(o.root);
|
|
2937
3063
|
for (const change of changes) {
|
|
2938
|
-
if (change.action === "reset") sharing.
|
|
3064
|
+
if (change.action === "reset" && change.role === "owner") sharing.resetOwner();
|
|
3065
|
+
else if (change.action === "reset") sharing.resetLink(change.role);
|
|
2939
3066
|
else if (change.action === "role") {
|
|
2940
3067
|
if (!sharing.setRole(String(change.guest), change.role)) o.fail("SHARE_REFUSED", `No guest ${String(change.guest)}, or the ${String(change.role)} link is off.`);
|
|
2941
3068
|
} else sharing.setLink(change.role, change.on === true);
|
|
@@ -2981,6 +3108,30 @@ async function shareCommand(o) {
|
|
|
2981
3108
|
}
|
|
2982
3109
|
var NOT_SHARED_HINT = "share <root> with no options shares it.";
|
|
2983
3110
|
var lanUrls = (view) => (Array.isArray(view.addresses) ? view.addresses : []).map((a) => `http://${String(a)}:${String(view.port)}${String(view.page ?? "/")}`);
|
|
3111
|
+
async function remoteOn(url, flags, fail2, stopCommand) {
|
|
3112
|
+
const given = flags.get("remote");
|
|
3113
|
+
if (given === void 0) return {};
|
|
3114
|
+
const call = async (action) => {
|
|
3115
|
+
const response = await fetch(new URL("__share", url), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(action) });
|
|
3116
|
+
const body = await response.json().catch(() => ({}));
|
|
3117
|
+
if (response.ok) return body;
|
|
3118
|
+
if (response.status === 400 && /^action: /.test(String(body.message ?? ""))) {
|
|
3119
|
+
fail2("REMOTE_UNSUPPORTED", `The editor server running for this folder is of an earlier release and cannot open it to another device. Run ${stopCommand}, then open again.`);
|
|
3120
|
+
}
|
|
3121
|
+
return fail2("SHARE_REFUSED", String(body.message ?? `the server answered ${response.status}`));
|
|
3122
|
+
};
|
|
3123
|
+
if (given === "off") {
|
|
3124
|
+
await call({ action: "remote", on: false });
|
|
3125
|
+
return { remote: false };
|
|
3126
|
+
}
|
|
3127
|
+
const view = await call({ action: "remote", on: true, ...given && given !== "true" ? { host: given } : {} });
|
|
3128
|
+
const link = view.remoteLink;
|
|
3129
|
+
if (typeof link?.url !== "string") return fail2("SHARE_REFUSED", "The server did not give the owner's link.");
|
|
3130
|
+
return {
|
|
3131
|
+
remoteUrl: link.url,
|
|
3132
|
+
remoteNote: `remoteUrl opens the editor as its owner from another device that reaches this computer at ${String(link.address)} (an app reached remotely, a phone, a laptop on the same network or tailnet). It is the owner's own key, over plain http: open it only on your own devices and give it to nobody (to let others in, \`share\`). \`--remote=off\` closes it; \`share <root> --reset owner\` replaces it.`
|
|
3133
|
+
};
|
|
3134
|
+
}
|
|
2984
3135
|
async function lanOn(url, flags, fail2) {
|
|
2985
3136
|
if (!flags.has("lan")) return {};
|
|
2986
3137
|
return { lan: lanUrls(await shareCall(url, { action: "lan", on: true }, fail2)) };
|
|
@@ -3902,7 +4053,7 @@ function print(value, compact = false) {
|
|
|
3902
4053
|
process.stdout.write(`${compact ? JSON.stringify(value) : JSON.stringify(value, null, 2)}
|
|
3903
4054
|
`);
|
|
3904
4055
|
}
|
|
3905
|
-
var BARE_FLAGS = ["text", "json", "wait", "all", "resolve", "reopen", "lan", "off", "force"];
|
|
4056
|
+
var BARE_FLAGS = ["text", "json", "wait", "all", "resolve", "reopen", "lan", "remote", "off", "force"];
|
|
3906
4057
|
function parseArgs(argv) {
|
|
3907
4058
|
const positional = [];
|
|
3908
4059
|
const flags = /* @__PURE__ */ new Map();
|
|
@@ -4822,12 +4973,12 @@ var lanOn2 = (url, flags) => lanOn(url, flags, fail);
|
|
|
4822
4973
|
async function open(root, flags) {
|
|
4823
4974
|
const existing = await runningServer2(root);
|
|
4824
4975
|
if (existing) {
|
|
4825
|
-
print({ url: existing.url, root, pid: existing.pid, reused: true, ...await lanOn2(existing.url, flags), log: path9.join(root, LOG_FILE), stop: cmd("stop", root) }, true);
|
|
4976
|
+
print({ url: existing.url, root, pid: existing.pid, reused: true, ...await lanOn2(existing.url, flags), ...await remoteOn(existing.url, flags, fail, cmd("stop", root)), log: path9.join(root, LOG_FILE), stop: cmd("stop", root) }, true);
|
|
4826
4977
|
return;
|
|
4827
4978
|
}
|
|
4828
4979
|
const state = await startBackground(root, flags);
|
|
4829
4980
|
const empty = !fs10.existsSync(docFile(root, ["board", "meta"]));
|
|
4830
|
-
print({ url: state.url, root, pid: state.pid, ...state.lan?.length ? { lan: state.lan } : {}, log: path9.join(root, LOG_FILE), stop: cmd("stop", root), ...empty ? { note: "board/meta is not written yet: the page shows an empty board until it is." } : {} }, true);
|
|
4981
|
+
print({ url: state.url, root, pid: state.pid, ...state.lan?.length ? { lan: state.lan } : {}, ...await remoteOn(state.url, flags, fail, cmd("stop", root)), log: path9.join(root, LOG_FILE), stop: cmd("stop", root), ...empty ? { note: "board/meta is not written yet: the page shows an empty board until it is." } : {} }, true);
|
|
4831
4982
|
}
|
|
4832
4983
|
async function startBackground(root, flags) {
|
|
4833
4984
|
const port = flags.has("port") ? Number(flags.get("port")) : DEFAULT_PORT;
|
|
@@ -5022,6 +5173,8 @@ Usage:
|
|
|
5022
5173
|
node scripts/artifact.mjs serve <root> [--port n] [--lan] the same server, in the foreground
|
|
5023
5174
|
[--agent-hook "<command>"] open/serve: run this for each comment sent to the agent
|
|
5024
5175
|
(the comment as JSON on stdin); --lan: shared too
|
|
5176
|
+
[--remote[=<host>]] open: also open it to you on another device (<host>: how
|
|
5177
|
+
that device reaches this computer); prints remoteUrl
|
|
5025
5178
|
node scripts/artifact.mjs stop <root> stop the background board server
|
|
5026
5179
|
node scripts/artifact.mjs share <root> share on the local network; prints a link per role
|
|
5027
5180
|
[--off] [--reset R] [--enable R] [--disable R]
|