dsh-claude-data 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/docs/DETAIL.zh.md +1 -1
- package/lib/index.js +5 -4
- package/locale/zh.json +1 -1
- package/package.json +8 -8
- package/skills/claude-data/scripts/artifact.mjs +178 -25
package/docs/DETAIL.zh.md
CHANGED
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dsh-claude-data 2.14.
|
|
1
|
+
// dsh-claude-data 2.14.1: built from baoyu-design.
|
|
2
2
|
var define_PLUGIN_default = { name: "claude-data", provider: "dsh-claude-data", skills: [{ name: "claude-data", description: "Use this for a local, data-backed dashboard, metrics page, KPI tracker, scorecard, data visualization or recurring report that opens in the bundled live editor: a page or scroll story of charts, numbers and tables over datasets (a CSV/TSV/JSON or text file the user has, or data you gather and attach), with a Sources panel that shows where every number comes from. Read it when creating, filling, refreshing or revising one. You write the datasets, then the page as plain HTML, into the dashboard's own store with this skill's script. Not for a static HTML mockup or a picture of a dashboard.", 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/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-claude-data",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.1",
|
|
4
4
|
"description": "DeepSeek Harness plugin: the claude-data skill from baoyu-design. The agent builds data-backed dashboards as files and opens them in a local dashboard editor.",
|
|
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": {
|
|
@@ -244,6 +244,7 @@ function sendFile(request, response, file, headers) {
|
|
|
244
244
|
var errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
245
245
|
|
|
246
246
|
import { spawn } from "node:child_process";
|
|
247
|
+
import dns from "node:dns/promises";
|
|
247
248
|
import { EventEmitter } from "node:events";
|
|
248
249
|
import fs3 from "node:fs";
|
|
249
250
|
import http from "node:http";
|
|
@@ -299,6 +300,7 @@ var Sharing = class _Sharing {
|
|
|
299
300
|
if (isRecord(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
|
|
300
301
|
}
|
|
301
302
|
}
|
|
303
|
+
if (isRecord(raw.owner) && typeof raw.owner.token === "string" && raw.owner.token.length >= 32) state.owner = { token: raw.owner.token };
|
|
302
304
|
if (Array.isArray(raw.guests)) {
|
|
303
305
|
for (const g of raw.guests) {
|
|
304
306
|
if (!isRecord(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
|
|
@@ -329,6 +331,27 @@ var Sharing = class _Sharing {
|
|
|
329
331
|
}
|
|
330
332
|
return null;
|
|
331
333
|
}
|
|
334
|
+
/** The owner's link token (`state.owner`), made and saved the first time it is asked for. */
|
|
335
|
+
ownerToken() {
|
|
336
|
+
if (!this.state.owner) {
|
|
337
|
+
this.state.owner = { token: token(32) };
|
|
338
|
+
this.save();
|
|
339
|
+
}
|
|
340
|
+
return this.state.owner.token;
|
|
341
|
+
}
|
|
342
|
+
/** Whether `given` is the owner's link token (a cookie or a link's). */
|
|
343
|
+
isOwnerToken(given) {
|
|
344
|
+
const own = this.state.owner?.token;
|
|
345
|
+
if (!given || !own) return false;
|
|
346
|
+
const a = Buffer.from(given);
|
|
347
|
+
const b = Buffer.from(own);
|
|
348
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
349
|
+
}
|
|
350
|
+
/** A new owner's link: the old one, and every browser that came in through it, stop working. */
|
|
351
|
+
resetOwner() {
|
|
352
|
+
this.state.owner = { token: token(32) };
|
|
353
|
+
this.save();
|
|
354
|
+
}
|
|
332
355
|
/** The guest a cookie names, if they still have access (their link on and not reset). */
|
|
333
356
|
guest(secret) {
|
|
334
357
|
if (!secret) return null;
|
|
@@ -590,6 +613,8 @@ var Hub = class {
|
|
|
590
613
|
hosts = /* @__PURE__ */ new Set();
|
|
591
614
|
/** One listener per LAN address while any site is open to the network. */
|
|
592
615
|
lanServers = /* @__PURE__ */ new Map();
|
|
616
|
+
/** Names that resolve to an address listened on, asked for by `allowName` (lower case, no port). */
|
|
617
|
+
lanNames = /* @__PURE__ */ new Set();
|
|
593
618
|
network;
|
|
594
619
|
constructor(options) {
|
|
595
620
|
this.echo = options.echo;
|
|
@@ -605,7 +630,8 @@ var Hub = class {
|
|
|
605
630
|
},
|
|
606
631
|
addresses: () => [...this.lanServers.keys()],
|
|
607
632
|
open: () => this.openLan(),
|
|
608
|
-
release: () => this.releaseLan()
|
|
633
|
+
release: () => this.releaseLan(),
|
|
634
|
+
allowName: (name) => this.allowName(name)
|
|
609
635
|
};
|
|
610
636
|
}
|
|
611
637
|
createServer() {
|
|
@@ -724,9 +750,9 @@ var Hub = class {
|
|
|
724
750
|
}
|
|
725
751
|
return [...this.lanServers.keys()];
|
|
726
752
|
}
|
|
727
|
-
/** A site stopped sharing: the listeners stay while another is shared. */
|
|
753
|
+
/** A site stopped sharing: the listeners stay while another is shared, or open to its owner on another device. */
|
|
728
754
|
releaseLan() {
|
|
729
|
-
if (![...this.sites.values()].some((site) => site.share.
|
|
755
|
+
if (![...this.sites.values()].some((site) => site.share.listening)) this.closeLan();
|
|
730
756
|
}
|
|
731
757
|
closeLan() {
|
|
732
758
|
for (const server of this.lanServers.values()) {
|
|
@@ -734,8 +760,30 @@ var Hub = class {
|
|
|
734
760
|
server.closeAllConnections?.();
|
|
735
761
|
}
|
|
736
762
|
this.lanServers.clear();
|
|
763
|
+
this.lanNames.clear();
|
|
764
|
+
}
|
|
765
|
+
/** The machine's own names, as hostAllowed takes them. */
|
|
766
|
+
isMachineName(name) {
|
|
767
|
+
const machine = os2.hostname().toLowerCase();
|
|
768
|
+
return name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
|
|
737
769
|
}
|
|
738
|
-
/**
|
|
770
|
+
/**
|
|
771
|
+
* Answer at `name` too while listening: an address listened on, the machine's name, or a name that resolves to an
|
|
772
|
+
* address listened on (looked up once, now: Tailscale's MagicDNS name, a hosts entry).
|
|
773
|
+
*/
|
|
774
|
+
async allowName(name) {
|
|
775
|
+
const lower = name.toLowerCase();
|
|
776
|
+
if (!lower || !this.lanServers.size) return false;
|
|
777
|
+
if (this.lanServers.has(lower) || this.isMachineName(lower) || this.lanNames.has(lower)) return true;
|
|
778
|
+
const found = await dns.lookup(lower, { all: true, family: 4 }).catch(() => []);
|
|
779
|
+
if (!found.some((entry) => this.lanServers.has(entry.address))) return false;
|
|
780
|
+
this.lanNames.add(lower);
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* A Host header this hub answers: its own names, and while something is shared (or open to its owner on another
|
|
785
|
+
* device), its LAN addresses, its machine name and the names allowName took.
|
|
786
|
+
*/
|
|
739
787
|
hostAllowed(request) {
|
|
740
788
|
const host = request.headers.host ?? "";
|
|
741
789
|
if (this.hosts.has(host)) return true;
|
|
@@ -743,8 +791,7 @@ var Hub = class {
|
|
|
743
791
|
const suffix = `:${this.port}`;
|
|
744
792
|
if (!host.endsWith(suffix)) return false;
|
|
745
793
|
const name = host.slice(0, -suffix.length).toLowerCase();
|
|
746
|
-
|
|
747
|
-
return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
|
|
794
|
+
return this.lanServers.has(name) || this.isMachineName(name) || this.lanNames.has(name);
|
|
748
795
|
}
|
|
749
796
|
// ---- requests --------------------------------------------------------------------------------------------------
|
|
750
797
|
/** The site whose address `pathname` is under, with the path below it. */
|
|
@@ -1340,6 +1387,12 @@ function commentsViewer(viewer) {
|
|
|
1340
1387
|
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1341
1388
|
var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1342
1389
|
var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
1390
|
+
function hostName(host) {
|
|
1391
|
+
const value = host.trim().toLowerCase();
|
|
1392
|
+
if (value.startsWith("[")) return value.slice(1, value.indexOf("]") > 0 ? value.indexOf("]") : void 0);
|
|
1393
|
+
const colons = value.split(":").length - 1;
|
|
1394
|
+
return colons === 1 ? value.slice(0, value.indexOf(":")) : value;
|
|
1395
|
+
}
|
|
1343
1396
|
function localOwner() {
|
|
1344
1397
|
let name = "You";
|
|
1345
1398
|
try {
|
|
@@ -1361,6 +1414,8 @@ var ShareHost = class {
|
|
|
1361
1414
|
page;
|
|
1362
1415
|
/** Open to the local network now (the hub's listeners are up, for this artifact too). */
|
|
1363
1416
|
on = false;
|
|
1417
|
+
/** Open to its owner on another device (`open --remote`): the listeners are up, guests stay out unless `on`. */
|
|
1418
|
+
remote = false;
|
|
1364
1419
|
live = /* @__PURE__ */ new Set();
|
|
1365
1420
|
constructor(options) {
|
|
1366
1421
|
this.options = options;
|
|
@@ -1386,6 +1441,10 @@ var ShareHost = class {
|
|
|
1386
1441
|
get shared() {
|
|
1387
1442
|
return this.on;
|
|
1388
1443
|
}
|
|
1444
|
+
/** Someone may reach it over the network now (guests, or its owner on another device): the hub keeps listening. */
|
|
1445
|
+
get listening() {
|
|
1446
|
+
return this.on || this.remote;
|
|
1447
|
+
}
|
|
1389
1448
|
/** The artifact's name, for the join page and the list of what is open at the server's root. */
|
|
1390
1449
|
title() {
|
|
1391
1450
|
return this.options.title();
|
|
@@ -1408,30 +1467,61 @@ var ShareHost = class {
|
|
|
1408
1467
|
this.options.log(`shared on the local network: ${addresses.map((a) => `http://${a}:${this.port}${this.page}`).join(", ") || "(no LAN address)"}`);
|
|
1409
1468
|
return addresses;
|
|
1410
1469
|
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Opens the artifact to its owner on another device, or closes that, and returns the owner's link, at `host` when
|
|
1472
|
+
* that is this computer's (the address the owner's browser already reaches it by), else at its first network address.
|
|
1473
|
+
* Guests are not let in by it: that is `setLan`.
|
|
1474
|
+
*/
|
|
1475
|
+
async setRemote(on, host) {
|
|
1476
|
+
if (!on) {
|
|
1477
|
+
this.remote = false;
|
|
1478
|
+
for (const live of [...this.live]) if (live.viewer.remote) live.close("unshared");
|
|
1479
|
+
this.network.release();
|
|
1480
|
+
return null;
|
|
1481
|
+
}
|
|
1482
|
+
const addresses = await this.network.open();
|
|
1483
|
+
if (!addresses.length) {
|
|
1484
|
+
this.network.release();
|
|
1485
|
+
throw new ShareError("This computer has no network address to open it at.");
|
|
1486
|
+
}
|
|
1487
|
+
this.remote = true;
|
|
1488
|
+
const name = hostName(host ?? "");
|
|
1489
|
+
const sorted = [...addresses].sort((a, b) => Number(isTailnet(a)) - Number(isTailnet(b)));
|
|
1490
|
+
const address = name && (addresses.includes(name) || await this.network.allowName?.(name)) ? name : sorted[0];
|
|
1491
|
+
const url = `http://${address.includes(":") ? `[${address}]` : address}:${this.port}${this.page}o/${this.sharing.ownerToken()}`;
|
|
1492
|
+
this.options.log(`open to its owner on another device at http://${address}:${this.port}${this.page}`);
|
|
1493
|
+
return { url, address };
|
|
1494
|
+
}
|
|
1411
1495
|
/** The artifact left the hub: it stays shared in .sharing.json, for the next time it opens. */
|
|
1412
1496
|
close() {
|
|
1413
1497
|
this.on = false;
|
|
1498
|
+
this.remote = false;
|
|
1414
1499
|
this.network.release();
|
|
1415
1500
|
}
|
|
1416
1501
|
// ---- who is asking ---------------------------------------------------------------------------------------------
|
|
1417
|
-
cookie(request) {
|
|
1502
|
+
cookie(request, name = this.options.cookieName) {
|
|
1418
1503
|
for (const part of (request.headers.cookie ?? "").split(";")) {
|
|
1419
1504
|
const eq = part.indexOf("=");
|
|
1420
|
-
if (eq > 0 && part.slice(0, eq).trim() ===
|
|
1505
|
+
if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
1421
1506
|
}
|
|
1422
1507
|
return void 0;
|
|
1423
1508
|
}
|
|
1509
|
+
/** The cookie the owner's link leaves in a browser on another device. */
|
|
1510
|
+
get ownerCookie() {
|
|
1511
|
+
return `${this.options.cookieName}_owner`;
|
|
1512
|
+
}
|
|
1424
1513
|
/**
|
|
1425
|
-
* Who a request is: the owner over loopback,
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1428
|
-
* what a role sees).
|
|
1514
|
+
* Who a request is: the owner over loopback, or by the owner's link's cookie while the artifact is open to them on
|
|
1515
|
+
* another device; a guest by their cookie (while their link is on and the artifact is shared), else nobody. Loopback
|
|
1516
|
+
* is always the owner, so trying a link on this computer can't lock the owner out of their own artifact; a guest
|
|
1517
|
+
* comes over a LAN address (this computer's own included: that is how the owner sees what a role sees).
|
|
1429
1518
|
*/
|
|
1430
1519
|
isLocal(request) {
|
|
1431
1520
|
return this.options.local ? this.options.local(request) : isLoopback(request.socket.remoteAddress);
|
|
1432
1521
|
}
|
|
1433
1522
|
viewerOf(request) {
|
|
1434
1523
|
if (this.isLocal(request)) return { role: "owner", user: this.options.owner };
|
|
1524
|
+
if (this.remote && this.sharing.isOwnerToken(this.cookie(request, this.ownerCookie))) return { role: "owner", user: this.options.owner, remote: true };
|
|
1435
1525
|
const guest = this.on ? this.sharing.guest(this.cookie(request)) : null;
|
|
1436
1526
|
if (!guest) return null;
|
|
1437
1527
|
this.sharing.seen(guest);
|
|
@@ -1465,7 +1555,8 @@ var ShareHost = class {
|
|
|
1465
1555
|
const p = new URL(request.url ?? "/", "http://local").pathname;
|
|
1466
1556
|
const method = request.method ?? "GET";
|
|
1467
1557
|
const link = /^\/s\/([A-Za-z0-9_-]{16,64})$/.exec(p);
|
|
1468
|
-
const
|
|
1558
|
+
const ownerLink = /^\/o\/([A-Za-z0-9_-]{16,64})$/.exec(p);
|
|
1559
|
+
const ours = link || ownerLink || p === "/__share" || p.startsWith("/__share/");
|
|
1469
1560
|
if (!ours) return false;
|
|
1470
1561
|
if (method === "POST" && !sameOrigin(request)) {
|
|
1471
1562
|
request.resume();
|
|
@@ -1477,6 +1568,12 @@ var ShareHost = class {
|
|
|
1477
1568
|
else response.writeHead(405, { Allow: "GET, POST" }).end();
|
|
1478
1569
|
return true;
|
|
1479
1570
|
}
|
|
1571
|
+
if (ownerLink) {
|
|
1572
|
+
request.resume();
|
|
1573
|
+
if (method === "GET" || method === "HEAD") this.ownerIn(request, response, ownerLink[1]);
|
|
1574
|
+
else response.writeHead(405, { Allow: "GET" }).end();
|
|
1575
|
+
return true;
|
|
1576
|
+
}
|
|
1480
1577
|
const viewer = this.viewerOf(request);
|
|
1481
1578
|
if (!viewer) {
|
|
1482
1579
|
this.denied(request, response);
|
|
@@ -1560,6 +1657,24 @@ var ShareHost = class {
|
|
|
1560
1657
|
const url = `http://127.0.0.1:${this.port}${this.page}`;
|
|
1561
1658
|
return `<p class="note">${esc(t.ownerHere)} <a href="${esc(url)}">${esc(url)}</a></p>`;
|
|
1562
1659
|
}
|
|
1660
|
+
/**
|
|
1661
|
+
* GET /o/<token>: the owner's link. On another device it leaves the owner's cookie (while the artifact is open to them
|
|
1662
|
+
* there) and goes on to the page, its query kept (`?comment=` opens a thread); over loopback it is the page anyway.
|
|
1663
|
+
*/
|
|
1664
|
+
ownerIn(request, response, token2) {
|
|
1665
|
+
const local = this.isLocal(request);
|
|
1666
|
+
if (!this.sharing.isOwnerToken(token2) || !local && !this.remote) {
|
|
1667
|
+
const t = this.words(request);
|
|
1668
|
+
return this.sendPage(response, 404, t.linkGoneTitle, `<h1>${esc(t.linkGoneTitle)}</h1><p>${esc(t.linkGone)}</p>`);
|
|
1669
|
+
}
|
|
1670
|
+
const search = new URL(request.url ?? "/", "http://local").search;
|
|
1671
|
+
const headers = { Location: `${this.page}${search}`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
|
|
1672
|
+
if (!local) {
|
|
1673
|
+
headers["Set-Cookie"] = `${this.ownerCookie}=${token2}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`;
|
|
1674
|
+
this.options.log(`share: the owner came in from another device (${request.socket.remoteAddress})`);
|
|
1675
|
+
}
|
|
1676
|
+
response.writeHead(303, headers).end();
|
|
1677
|
+
}
|
|
1563
1678
|
/** GET /s/<token>: the name form (or straight in, for a guest already in through this link); POST: join. */
|
|
1564
1679
|
async join(request, response, method, token2) {
|
|
1565
1680
|
const t = this.words(request);
|
|
@@ -1617,6 +1732,7 @@ var ShareHost = class {
|
|
|
1617
1732
|
return {
|
|
1618
1733
|
title: this.options.title(),
|
|
1619
1734
|
lan: this.on,
|
|
1735
|
+
remote: this.remote,
|
|
1620
1736
|
port: this.port,
|
|
1621
1737
|
page: this.page,
|
|
1622
1738
|
addresses,
|
|
@@ -1628,11 +1744,13 @@ var ShareHost = class {
|
|
|
1628
1744
|
}
|
|
1629
1745
|
/**
|
|
1630
1746
|
* The owner's sharing calls: `lan` {on}, `link` {role, on}, `reset` {role} (a new link: the old one and the guests
|
|
1631
|
-
* who came through it stop working), `role` {guest, role} (a guest's role, while
|
|
1632
|
-
* {guest}
|
|
1747
|
+
* who came through it stop working; role `owner` the owner's own link), `role` {guest, role} (a guest's role, while
|
|
1748
|
+
* that role's link is on), `remove` {guest}, `remote` {on, host?} (open to the owner on another device: the answer
|
|
1749
|
+
* then has `remoteLink` {url, address}, the owner's link). Answers the view after.
|
|
1633
1750
|
*/
|
|
1634
1751
|
async share(action) {
|
|
1635
1752
|
const role = action.role;
|
|
1753
|
+
let remote = null;
|
|
1636
1754
|
switch (action.action) {
|
|
1637
1755
|
case "lan": {
|
|
1638
1756
|
const addresses = await this.setLan(action.on === true);
|
|
@@ -1644,9 +1762,17 @@ var ShareHost = class {
|
|
|
1644
1762
|
this.sharing.setLink(role, action.on === true);
|
|
1645
1763
|
break;
|
|
1646
1764
|
case "reset":
|
|
1647
|
-
if (
|
|
1765
|
+
if (role === "owner") {
|
|
1766
|
+
this.sharing.resetOwner();
|
|
1767
|
+
for (const live of [...this.live]) if (live.viewer.remote) live.close("revoked");
|
|
1768
|
+
break;
|
|
1769
|
+
}
|
|
1770
|
+
if (!isGuestRole(role)) throw new ShareError("role: owner, editor, commenter or viewer");
|
|
1648
1771
|
this.sharing.resetLink(role);
|
|
1649
1772
|
break;
|
|
1773
|
+
case "remote":
|
|
1774
|
+
remote = await this.setRemote(action.on === true, typeof action.host === "string" ? action.host : void 0);
|
|
1775
|
+
break;
|
|
1650
1776
|
case "role": {
|
|
1651
1777
|
if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
|
|
1652
1778
|
if (typeof action.guest !== "string" || !this.sharing.state.guests.some((g) => g.id === action.guest)) throw new ShareError("no such guest");
|
|
@@ -1658,11 +1784,11 @@ var ShareHost = class {
|
|
|
1658
1784
|
if (typeof action.guest !== "string" || !this.sharing.removeGuest(action.guest)) throw new ShareError("no such guest");
|
|
1659
1785
|
break;
|
|
1660
1786
|
default:
|
|
1661
|
-
throw new ShareError("action: lan, link, reset, role or
|
|
1787
|
+
throw new ShareError("action: lan, link, reset, role, remove or remote");
|
|
1662
1788
|
}
|
|
1663
1789
|
this.options.log(`share: ${JSON.stringify(action)}`);
|
|
1664
1790
|
this.dropRevoked();
|
|
1665
|
-
return this.shareView();
|
|
1791
|
+
return { ...this.shareView(), ...remote ? { remoteLink: remote } : {} };
|
|
1666
1792
|
}
|
|
1667
1793
|
async shareApi(request, response, method) {
|
|
1668
1794
|
if (method === "GET") return sendJson(response, 200, this.shareView());
|
|
@@ -2916,11 +3042,11 @@ async function shareCall(url, action, fail2) {
|
|
|
2916
3042
|
if (!response.ok) fail2("SHARE_REFUSED", String(body.message ?? `the server answered ${response.status}`));
|
|
2917
3043
|
return body;
|
|
2918
3044
|
}
|
|
2919
|
-
function rolesOf(flags, key, fail2) {
|
|
3045
|
+
function rolesOf(flags, key, fail2, owner = false) {
|
|
2920
3046
|
const given = flags.get(key);
|
|
2921
3047
|
if (given === void 0) return [];
|
|
2922
3048
|
const roles = given === "all" || given === "true" ? [...GUEST_ROLES] : given.split(",").map((r) => r.trim());
|
|
2923
|
-
for (const role of roles) if (!isGuestRole(role)) fail2("INVALID_ROLE", `--${key}: editor, commenter, viewer or all (got ${role}).`);
|
|
3049
|
+
for (const role of roles) if (!isGuestRole(role) && !(owner && role === "owner")) fail2("INVALID_ROLE", `--${key}: ${owner ? "owner, " : ""}editor, commenter, viewer or all (got ${role}).`);
|
|
2924
3050
|
return roles;
|
|
2925
3051
|
}
|
|
2926
3052
|
function guestRoles(flags, fail2) {
|
|
@@ -2935,7 +3061,7 @@ function guestRoles(flags, fail2) {
|
|
|
2935
3061
|
async function shareCommand(o) {
|
|
2936
3062
|
const off = o.flags.has("off");
|
|
2937
3063
|
const changes = [
|
|
2938
|
-
...rolesOf(o.flags, "reset", o.fail).map((role) => ({ action: "reset", role })),
|
|
3064
|
+
...rolesOf(o.flags, "reset", o.fail, true).map((role) => ({ action: "reset", role })),
|
|
2939
3065
|
...rolesOf(o.flags, "enable", o.fail).map((role) => ({ action: "link", role, on: true })),
|
|
2940
3066
|
...rolesOf(o.flags, "disable", o.fail).map((role) => ({ action: "link", role, on: false })),
|
|
2941
3067
|
...guestRoles(o.flags, o.fail)
|
|
@@ -2945,7 +3071,8 @@ async function shareCommand(o) {
|
|
|
2945
3071
|
if (!server) {
|
|
2946
3072
|
const sharing = new Sharing(o.root);
|
|
2947
3073
|
for (const change of changes) {
|
|
2948
|
-
if (change.action === "reset") sharing.
|
|
3074
|
+
if (change.action === "reset" && change.role === "owner") sharing.resetOwner();
|
|
3075
|
+
else if (change.action === "reset") sharing.resetLink(change.role);
|
|
2949
3076
|
else if (change.action === "role") {
|
|
2950
3077
|
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.`);
|
|
2951
3078
|
} else sharing.setLink(change.role, change.on === true);
|
|
@@ -2991,6 +3118,30 @@ async function shareCommand(o) {
|
|
|
2991
3118
|
}
|
|
2992
3119
|
var NOT_SHARED_HINT = "share <root> with no options shares it.";
|
|
2993
3120
|
var lanUrls = (view) => (Array.isArray(view.addresses) ? view.addresses : []).map((a) => `http://${String(a)}:${String(view.port)}${String(view.page ?? "/")}`);
|
|
3121
|
+
async function remoteOn(url, flags, fail2, stopCommand) {
|
|
3122
|
+
const given = flags.get("remote");
|
|
3123
|
+
if (given === void 0) return {};
|
|
3124
|
+
const call = async (action) => {
|
|
3125
|
+
const response = await fetch(new URL("__share", url), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(action) });
|
|
3126
|
+
const body = await response.json().catch(() => ({}));
|
|
3127
|
+
if (response.ok) return body;
|
|
3128
|
+
if (response.status === 400 && /^action: /.test(String(body.message ?? ""))) {
|
|
3129
|
+
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.`);
|
|
3130
|
+
}
|
|
3131
|
+
return fail2("SHARE_REFUSED", String(body.message ?? `the server answered ${response.status}`));
|
|
3132
|
+
};
|
|
3133
|
+
if (given === "off") {
|
|
3134
|
+
await call({ action: "remote", on: false });
|
|
3135
|
+
return { remote: false };
|
|
3136
|
+
}
|
|
3137
|
+
const view = await call({ action: "remote", on: true, ...given && given !== "true" ? { host: given } : {} });
|
|
3138
|
+
const link = view.remoteLink;
|
|
3139
|
+
if (typeof link?.url !== "string") return fail2("SHARE_REFUSED", "The server did not give the owner's link.");
|
|
3140
|
+
return {
|
|
3141
|
+
remoteUrl: link.url,
|
|
3142
|
+
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.`
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
2994
3145
|
async function lanOn(url, flags, fail2) {
|
|
2995
3146
|
if (!flags.has("lan")) return {};
|
|
2996
3147
|
return { lan: lanUrls(await shareCall(url, { action: "lan", on: true }, fail2)) };
|
|
@@ -3852,7 +4003,7 @@ function print(value, compact = false) {
|
|
|
3852
4003
|
process.stdout.write(`${compact ? JSON.stringify(value) : JSON.stringify(value, null, 2)}
|
|
3853
4004
|
`);
|
|
3854
4005
|
}
|
|
3855
|
-
var SWITCHES = /* @__PURE__ */ new Set(["text", "json", "wait", "all", "resolve", "reopen", "lan", "off", "background"]);
|
|
4006
|
+
var SWITCHES = /* @__PURE__ */ new Set(["text", "json", "wait", "all", "resolve", "reopen", "lan", "remote", "off", "background"]);
|
|
3856
4007
|
function parseArgs(argv) {
|
|
3857
4008
|
const positional = [];
|
|
3858
4009
|
const flags = /* @__PURE__ */ new Map();
|
|
@@ -5394,12 +5545,12 @@ var lanOn2 = (url, flags) => lanOn(url, flags, fail);
|
|
|
5394
5545
|
async function open(root, flags) {
|
|
5395
5546
|
const existing = await runningServer2(root);
|
|
5396
5547
|
if (existing) {
|
|
5397
|
-
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);
|
|
5548
|
+
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);
|
|
5398
5549
|
return;
|
|
5399
5550
|
}
|
|
5400
5551
|
const state = await startBackground(root, flags);
|
|
5401
5552
|
const empty = !fs10.existsSync(docFile(root, ["files", "index.html"]));
|
|
5402
|
-
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: "files/index.html is not written yet: the page is empty until it is." } : {} }, true);
|
|
5553
|
+
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: "files/index.html is not written yet: the page is empty until it is." } : {} }, true);
|
|
5403
5554
|
}
|
|
5404
5555
|
async function startBackground(root, flags) {
|
|
5405
5556
|
const port = flags.has("port") ? Number(flags.get("port")) : DEFAULT_PORTS.data;
|
|
@@ -5564,6 +5715,8 @@ Usage:
|
|
|
5564
5715
|
node scripts/artifact.mjs serve <root> [--port n] [--lan] the same server, in the foreground
|
|
5565
5716
|
[--agent-hook "<command>"] open/serve: run this for each comment sent to the agent
|
|
5566
5717
|
(the comment as JSON on stdin); --lan: shared too
|
|
5718
|
+
[--remote[=<host>]] open: also open it to you on another device (<host>: how
|
|
5719
|
+
that device reaches this computer); prints remoteUrl
|
|
5567
5720
|
node scripts/artifact.mjs stop <root> stop the background editor
|
|
5568
5721
|
node scripts/artifact.mjs share <root> share on the local network; prints a link per role
|
|
5569
5722
|
[--off] [--reset R] [--enable R] [--disable R]
|